Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

写在前面

前阵子看到有人在讨论 Agent 的 TUI 和 GUI,两派吵得挺热闹。

我刚好两边都做过算是比较深入的研究,用 swift 从零开始写过一个 TUI 渲染引擎,也写过 iOS 上的 Agent GUI 客户端.在创作和优化性能的过程中,记录下来不少的笔记和代码,以及各种踩坑和思考,我水平有限,最多只能算是抛砖引玉了.

写这组文章之前,我翻了一遍自己攒下来的东西:项目源码、调试笔记、性能分析记录、读过的博客和开源库的源码、知乎上的相关讨论。散在好几个文件夹里,有些是完整的文档,有些只是当时随手记的片段。觉得这些东西放着也是放着,不如整理出来。

于是重新梳理了一遍,缺的地方补研究,浅的地方往下挖。前前后后写了十几篇,未来可能还会更多,想到哪就写到哪,从 ANSI 转义序列到多端架构,从 Markdown 流式渲染的 O(n²) 陷阱到权限和 diff 的 UX 设计。

不是什么权威结论,就是一个人同时踩过 TUI 和 GUI 两条船之后,把踩过的坑和想清楚的事记下来。如果恰好对你有用,那就最好了。

Agent 时代的开发者界面(一):为什么 Agent 从 CLI/TUI 开始——兼论程序员的信任模型

系列定位:从做过移动端、桌面端、TUI 和全栈的视角,重新理解 AI Agent 时代的人机界面与开发者工作流。 本文是系列第一篇,建立对 Agent 为什么优先走终端路线的完整认知。


一、一个场景

一个 coding agent 接到“修复测试失败“的任务。它不是直接给你答案。它会:

rg "UserSession"                          # 搜索调用链
cargo test 2>&1 | head -40                # 跑测试,看失败输出

读文件,找到可疑点。修改。再跑测试。发现又失败了——不是刚才那个 case,是另一个被牵连的。再改。再跑。通过了。然后:

git diff                                 # 展示变更

等你确认要不要提交。

这个过程从头到尾、每一步都在终端里发生。与其说终端被选成了 agent 的界面,不如说 agent 的工作方式本来就是一个终端会话。

这不是巧合。2025-2026 年,Anthropic 的 Claude Code、OpenAI 的 Codex CLI、Google 的 Gemini CLI、Block 的 Goose、Aider(2023 年先行者)、Devin CLI(Cognition)、Qwen Code 等等,头部 coding agent 里,CLI/TUI-first 是压倒性多数的选择。这些团队并非请不起 GUI 设计师,而且实际上也在朝着 GUI 的方向前进。他们做了同一个选择,因为有一些结构性的原因。

补记(2026.09,发布前):这段判断和“压倒性多数“的说法形成于几个月前。到文章发布时,GUI 这侧的进展已经不止是“方向“:Anthropic 在 4 月给 Claude Code 的桌面端做了完整改版(为并行 agent 工作流设计);OpenAI 在 7 月把 Codex 应用并入了新版 ChatGPT 桌面应用;Google 的 Antigravity 从 IDE 长成了 agent-first 平台,9 月起托管 agent 直接跑在 Gemini API 上,还出了自己的 CLI。CLI/TUI 仍然是这些产品的地基——这篇讲的正是地基为什么先是终端——但“多数派“的叙事正在快速过期。这个趋势不推翻本文的论证,后面的“多端投影“框架就是为它准备的。


二、最直接的回答:工作对象决定了工作界面

Agent 的工作对象是什么?

  • 代码文件(文本)
  • 命令行工具(文本输入,文本输出)
  • 编译器 / linter / 测试框架(文本输出)
  • git diff / log / status(文本输出)
  • 日志和错误堆栈(文本)

所有这些,最自然的显示介质就是终端。终端就是为文本而生的。

反过来看 GUI 里展示这些东西的成本:

  • 代码块:需要语法高亮引擎 + 等宽字体 + 复制按钮
  • 终端输出:需要 ANSI 转义序列解析 + 虚拟终端模拟器
  • diff:需要并排对比视图 + 行级高亮 + 部分采纳/拒绝交互
  • 日志:需要虚拟滚动 + 搜索过滤 + 级别着色

每一项在 TUI 里是 print(),在 GUI 里是一个子系统。当然,print() 只是输出的起点,如果想让输出在流式场景下保持可读、可排版,是另一套引擎的成本,下一节就会看到。GUI 并非做不了,只是在 agent 还在快速迭代、工具调用协议还在变化、权限模型还在试探的阶段,每加一个 GUI 子系统就是一笔债务。

我之前看一个说法:做 GUI 要么 VSCode 换皮(功能上限被封死),要么自研(至少半年以上)。而 CLI 是每个 agent 无论如何都要提供的核心功能——SSH 进去、WSL 里跑、CI 里执行,CLI 是唯一通用解。这笔账算下来很朴素:GUI 太贵,CLI 绕不开。


三、“1D → 1D“这个说法对吗?

我看有人提过一个简洁的直觉判断:LLM 的输出是 1D 的(线性 token 流),CLI 也是 1D 的(线性文本输出),所以天然匹配。

这个判断对了一部分:对于最简单的“用户问 → AI 答“场景,确实如此。

token 到达 → 显示字符 → 下一个 token → 追加字符。算得上一条直线。

但 Agent 的真实输出不是一条直线。它是这样的:

用户消息
  ├── thinking(推理过程,可能很长)
  ├── 工具调用 1(开始 → 运行中 → 完成)
  │     └── 工具结果回传
  ├── 工具调用 2(可能与工具 1 并行,且可能比工具 1 先返回)
  ├── thinking(基于工具结果继续推理)
  └── 最终回复(流式输出)

这是一棵树。而且节点到达的顺序不满足这棵树的拓扑序(父节点、靠前的子树并不保证先到)。工具调用 2 可能比工具调用 1 先返回,thinking 块可能在流式输出的中途插入,用户随时可能按取消键。

CLI 之所以“看起来适配“,不是因为 Agent 的输出真的是 1D 的,而是 TUI 渲染引擎在背后做了大量工作,把一棵乱序到达的树强行展平成了线性输出。

这套展平机制(稳定槽位队列、shiftIndices 偏移追踪、thinking 原地替换、有上限的 FIFO 通知队列)的核心只有几百行 Swift 代码。本系列第 4 篇会详细拆解。这里我们先接受一个结论:“1D → 1D“是用户看到的最终效果,不是 Agent 的真实结构,而且这个效果的实现成本不低。


四、更深层的原因:Agent 的状态模型还没稳定

一个普通 GUI 应用的信息架构通常很清晰:列表、详情、表单、编辑器、设置、通知。这些状态是预设的、有限的、可以画成状态机的。

Agent 的真实流程更像一个动态 workflow engine:

用户提出自然语言目标
  → agent 自己拆任务
  → 读取上下文
  → 搜索文件
  → 执行命令
  → 遇到错误
  → 修改计划
  → 编辑文件
  → 重新验证
  → 请求权限
  → 等待用户介入
  → 中断后恢复
  → 总结结果

每一步都不可预测。你不知道下一个事件是 thinking 还是 tool call 还是 streaming text 还是 error。你不知道用户会不会中途打断。你不知道工具返回是 3 行还是 3000 行。

在状态模型还在快速变化的阶段,过早投入重型 GUI 是在为一个还没定型的系统设计固定布局。CLI/TUI 的处理方式是务实的:把复杂过程线性化为事件流,一行一行往下写。等到事件模型稳定了,再考虑 GUI 怎么投影这些事件。

这解释了为什么 Aider(2023)、Claude Code(2025)、Codex CLI(2025)都走了 CLI-first 路线——倒不是请不起人做界面,当时的首要任务是把 agent runtime、工具调用协议、权限模型和失败恢复跑通。CLI 是最低成本、最快反馈的实验台。


五、环境继承:零成本的“进入现场“

开发项目的真实环境通常很复杂:多语言、多包管理器、monorepo、私有依赖源、本地证书、SSH key、git credential、自定义 Makefile / Justfile、CI 脚本、shell profile、环境变量、docker / dev container……

CLI Agent 默认运行在项目目录中。它直接继承:

cwd             → 就在项目根目录
PATH            → 所有本地工具链可用
env             → 项目需要的环境变量都在
git config      → 用户身份、凭证、hooks
SSH config      → 私有仓库、远程服务器

零配置。cd ~/project && claude 就够了。

GUI 要做到同样可靠,需要额外处理一堆桌面端问题:

  • 文件系统权限(macOS sandbox、Windows UAC)
  • shell 环境继承(login shell vs non-login shell 差异)
  • 跨平台差异(路径分隔符、换行符、进程管理)
  • code signing、auto update、crash recovery
  • terminal pty 管理、child process lifecycle
  • 企业网络、proxy、VPN

每多一层桌面集成,就多一层出错的可能。在“继承开发环境“这个维度上,CLI 是最可靠的方案。


六、中场过渡:这些解释了“为什么 CLI/TUI 先出现“,但还有一个更深的问题

以上说了四个原因:

  1. 工作对象是文本 → 终端最自然
  2. 做 GUI 太贵,CLI 绕不开 → 经济上的必然
  3. 状态模型未稳定 → CLI 是低成本实验台
  4. 环境继承零成本 → 直接进入工程现场

这些足以解释为什么 agent 从 CLI/TUI 起步。但还有一个更深的问题:为什么程序员更信任 CLI/TUI agent?

因为 CLI/TUI 满足了一个程序员对开发工具最核心的三个需求。


七、程序员信任开发工具的三个条件

7.1 可观察

程序员接受一个 agent 的前提是:我能看到它在做什么。

不是“我相信它“,是“我能验证它“。具体来说:

  • 它准备改哪些文件?
  • 为什么改这里?
  • 跑了什么命令?命令输出是什么?
  • 测试失败在哪里?哪一行?
  • diff 是否合理?有没有误改无关文件?
  • 是否访问了网络?是否改了 lockfile?
  • 是否提交或推送了?

CLI/TUI 在这个维度上有天然优势——命令、输出、diff、错误堆栈,全部以原生形态展示在终端里。GUI 要做到同等可见性,需要设计专门的信息面板——命令历史面板、输出日志面板、diff 审查面板。这部分的复杂性远比 TUI 要大的多.

7.2 可复现

agent 做的事,用户自己能复现吗?

CLI Agent 的每一个操作——读文件、跑测试、git diff——用的都是项目里的真实命令。输出是这些命令的真实输出。如果 agent 说“测试通过了“,用户可以在自己的终端里跑同一行命令验证。

更重要的是:如果 agent 做错了,用户可以接管。git reset HEAD~1,手动改,重跑。agent 没有引入一个“只有 agent 能操作“的中间层。

7.3 可回滚

所有 agent 的代码变更都在 git 工作区里。

用户可以 git diff 逐行审查,可以 git add -p 分块暂存,可以 git checkout -- <file> 撤销。git 本身就是最可靠的安全网——不是因为 agent 不会犯错,而是因为错误可以被精确回退。

这三条:可观察、可复现、可回滚,合在一起,构成了程序员对自动化工具的信任边界。程序员不讨厌自动化。但程序员讨厌不可解释的自动化,一个黑箱告诉你“搞定了“,但你看不到它做了什么、无法验证、也无法回退。


八、三种界面在信任模型上的差异

CLI/TUIGUI/DesktopIDE 插件
可观察命令和输出原样展示,diff 原生渲染需要设计专门的信息面板与编辑器深度集成,但 agent 操作的边界可能模糊
可复现每个命令都可以复制、重跑命令可能被抽象为按钮操作取决于插件暴露多少底层命令
可回滚git 工作区原生可见,用户完全掌控git 操作可能被 GUI 封装编辑器的 undo + git 双重保障
信任建立速度快——看到命令和输出就信任了中——需要额外验证中——编辑器的 undo 历史提供安全感
黑箱风险低——所有操作以文本形式暴露高——容易被封装成“一键完成“中——可能在编辑器后台悄悄改文件

这张表不是要证明 CLI/TUI 在所有维度上优于 GUI,事实上不管是我自己的思考还是现实发展都证明了 GUI 化是发展的目标。GUI 在 diff 审查(并排对比、行级采纳/拒绝)、长会话管理(cell 复用、分页加载)、多模态附件(图片、文件预览)上有结构性优势。这些在本系列后篇会展开。

这里要说明的是另一件事:CLI/TUI 在当前阶段胜出,理由不在“更好“,在这三条信任基线它天然满足。做 GUI 的时候如果忽视了这三个维度,把 agent 包装成一个“一键完成“的按钮,反而会失去开发者用户。


九、不只是“早期形态“

有一种观点认为 CLI/TUI 只是 agent 的“早期原型“,等产品成熟了自然会转向 GUI。目前是探索 Agentic 框架的阶段,界面是次要的——Claude Desktop 和 Codex App(2026 年 7 月起已整合进 ChatGPT 桌面应用)的出现似乎也在印证这个方向。

这个判断对了一半。GUI 确实会在更广泛的用户群中占据更大份额。非技术用户不会打开终端。

但另一半隐含了一个更强的假设:CLI/TUI 和 GUI 是线性进化关系,前者迟早被后者迭代掉。

这个假设成立吗?SSH 到远程服务器跑 agent、CI/CD 流水线里集成 agent、在 tmux 里恢复一个跑了 3 小时的会话——这些场景里,CLI/TUI 的位置到底是什么?要完整回答这个问题,“进化论“这个框架不够用。后面会给出另一个框架——Event Sourcing:CLI/TUI 和 GUI 的关系不是谁迭代谁,而是同一条事件流在不同介质上的投影。这里先按下不表。


十、这篇的收束

这篇文章试图回答一个问题:为什么 2025-2026 年的头部 coding agent 在一开始绝大多数选择了 CLI/TUI-first,而且这不太可能是一个会被“快速迭代掉“的早期选择。

三条线汇总:

  1. 工作对象和工作方式决定了起点 agent 操作的是代码、命令、git、测试、日志——终端生态是原生匹配。GUI 要为每一样东西写一个子系统,工程成本不在同一量级。

  2. 状态模型未稳定 + 环境继承零成本 agent 的工作流是动态的、不确定的、会中断和恢复的。CLI 用线性事件流处理复杂性,同时零配置继承开发环境的全部上下文。

  3. 信任模型天然匹配 可观察(命令和输出原样展示)、可复现(每个操作都可以手动重跑)、可回滚(一切在 git 工作区里)。归根结底,终端让 agent 的行为可被审计。

CLI/TUI 是 Agent 在“执行阶段“的最优投影。 GUI 的机会在下一层——把复杂过程结构化、把 diff 和风险审查化、把多任务和团队协作产品化。这会是本系列后续各篇逐步展开的内容。


下一篇

接下来会沉到技术底层:TUI 到底是怎么渲染的? ANSI 转义序列、字符网格、物理行 vs 逻辑行、增量 diff、RenderLoop 帧调度——从 ForgeLoopTUI 的实际源码出发,讲清楚一个 TUI 渲染引擎到底在做什么。

Agent 时代的开发者界面(二):TUI 不是低配 GUI——字符网格里的渲染引擎

系列第二篇。上一篇讲了 Agent 为什么从 CLI/TUI 开始——工作对象、状态模型、环境继承、信任基线。这一篇沉到技术底层:TUI 到底是怎么渲染的?那个在终端里看似简单的界面,底下是一套什么样的引擎?


一、从一个常见的错觉说起

先看一眼这个领域的实际阵容:2025-2026 年的主流 coding agent——Claude Code、Codex CLI、Gemini CLI、Goose、Aider、Devin CLI、Qwen Code——绝大多数是 TUI-first。第 1 篇讲了这个选择的结构性原因(这份名单的时间锚点、以及 2026 年秋天 GUI 一侧的进展,见第 1 篇的补记),这一篇讲它们脚下的技术底座。

很多人对 TUI 的印象是“在终端里画界面“。用一些现成的框架——Bubble Tea(Go)、Ink/React(JS)、Ratatui(Rust)——组合几个组件,就能在终端里做出类似 GUI 的布局。

这条路能做出来。很多项目也确实走通了。但这条路和“手写一个 TUI 渲染引擎“之间,隔着一段很少有人细看过的距离。框架帮你做了什么?框架没帮你做什么?不搞清楚这些,TUI 就永远只是“GUI 的低配模仿“——而不是它本可以成为的东西。

这篇文章做的事:以 ForgeLoopTUI(一个 Swift 写的 TUI 渲染引擎)的实际源码为参照,逐层拆开一个 TUI 渲染引擎到底在处理什么问题。从 ANSI 字节到屏幕上的字符——完整的链路。


二、字符网格:一切困境的根源,也是一切优势的根源

TUI 的渲染介质是终端。终端输出的不是像素,是等宽字符网格。你可以把任何一个字符放在任何一个格子里——但放不下一个像素。

这个限制同时定义了 TUI 的上下限。

下限(做不到的):圆角、渐变阴影、可变字体、行内图片、自定义控件。一切只能靠字符和颜色表达。

上限(被低估的):24-bit true color 在现代终端里基本普及(iTerm2、kitty、WezTerm、Windows Terminal),语法高亮完全可以做到——只是需要手写 tokenizer。OSC 8 超链接(ESC]8;;<url>ESC\)可以让终端里的文本变得可点击跳转。Box-drawing 字符可以画出清晰的 UI 边界。

字符网格是限制——限制的是实现成本,不是表达上限。

但字符网格带来的麻烦远比“没有像素“更根本。真正的问题是:终端没有 undo。


三、终端没有 undo——你只能向前写

这是 TUI 和 GUI 最根本的范式差异。

GUI 的渲染模型接近声明式:你描述“这个 view 的文本、颜色“,框架负责把它画到正确位置。你想改?更新状态,框架自动重绘受影响区域。

TUI 是命令式的:你发出一个字符,它就写进了终端状态。想改?没有“撤销上一步渲染“的 API。你只能用 ANSI 转义序列回到目标位置 → 擦除 → 重写。

整个增量 diff 渲染机制的复杂性都源于这一点。如果你不需要 undo,你只需要 print()。但是你没法不需要 undo,因为 Agent 的流式输出会高频触发渲染,不断改写已显示的内容——所以你需要精确知道光标在哪、上一帧是什么、差异从第几行开始,然后生成一串 ANSI 序列,像外科手术一样修改终端屏幕上的内容。


四、ANSI 转义序列:半个世纪的遗产

4.1 工作原理

ANSI 转义序列的核心是 ESC(0x1B)后跟控制指令。最常见的 CSI(Control Sequence Introducer)格式:

ESC [ 参数 ; 参数 ... 中间字节 终结字节

常用序列速查(ANSI 坐标系是 1-based,行和列都从 1 开始):

序列含义
ESC[2J清除整个屏幕
ESC[H光标回到 (1,1)
ESC[5A光标上移 5 行
ESC[3B光标下移 3 行
ESC[10C光标右移 10 列
ESC[2K清除当前行
ESC[1;32mSGR: 粗体 + 绿色前景
ESC[0mSGR: 重置所有样式
ESC[5LIL: 在当前行插入 5 行
ESC[20GCHA: 光标水平移动到第 20 列

4.2 ANSI 状态机

只输出序列不需要解析器。但如果你要做 visibleWidth 计算——去掉 ANSI 序列后知道文本实际占据多少列——就需要能识别哪些字节是序列、哪些是可见字符。

ForgeLoopTUI 的 ANSIParser 实现了一个字节级有限状态机:

ground ── 0x1B ──→ escape ── '[' ──→ csiEntry
  ↑                 │                  │
  ├── 可见字符       │ 非法 escape      ├── param 字节 → csiParam
  │                  ↓                  │
  └── 文本事件     ground             ├── 中间字节 → csiIntermediate
                                      │
                                      └── 终结字节 → 输出 CSI 事件 → ground

它处理了分片到达(一次 write() 只收到半个序列)、连续 ESC、非法中间字节等所有边界。解析器本身不赋予样式语义——只区分“文本字符“和“CSI 序列“,语义由上层的 SGRState 和 VirtualTerminal 赋予。

4.3 终端能力的降级链

不同终端的能力差异巨大。ForgeLoopTUI 用 TerminalCapability 枚举建模了降级链:

truecolor (16M 色)   ← iTerm2, kitty, Windows Terminal, WezTerm
ansi256 (256 色)     ← tmux(screen-256color 常见配置), 老 xterm
ansi16 (16 色)       ← 最老的 xterm, 嵌入式终端
plain (无颜色)       ← 管道输出 (> file.txt), dumb terminal

NO_COLOR、TERM=dumb、isatty() 等环境检测,自动完成降级。GUI 里的 Dark Mode 自适应是手写的工作量——TUI 里靠终端颜色方案自动继承,但代价是你不知道用户终端到底是什么配色。


五、物理行 vs 逻辑行:所有计算的放大器

TUI 渲染中一个绕不开的错位:

你的程序操作的是逻辑行(\n 分隔),但 ANSI 光标移动需要物理行(wrap 后的显示器行数)。

GUI 里“一行太长自动换行“是文本引擎在布局阶段处理的,渲染层看到的就是已换行的结果。TUI 里换行是终端模拟器做的——你写一行 120 字符到 80 列宽终端,终端自己折成两行,程序不知道。

5.1 为什么物理行让增量 diff 翻倍复杂

增量 diff 的每一步都依赖物理行:

  1. firstDifferenceIndex 找到第一条变化的逻辑行 → O(min(m,n))
  2. 计算从帧头到差异点的物理行数 → O(n),累加每行 physicalRows
  3. 计算差异点之后旧帧的物理行数 → O(n)
  4. 用 ESC[rewindRows A] 回退光标
  5. 逐行 ESC[2K 清除
  6. 再回退,写入新内容

每一步都依赖 physicalRows 的精确性。如果终端 resize(用户拖动窗口),所有缓存的物理行数全部失效——lastFramePhysicalRows、lastCommittedPhysicalRows、lastLivePhysicalRows 必须立即重算。少算 1 行,光标回退就差 1 行,新内容覆盖旧内容,屏幕变乱码。

5.2 physicalRows 的计算

public func physicalRows(for line: String, width: Int) -> Int {
    guard width > 0 else { return 1 }
    let vw = visibleWidth(line)           // 去掉 ANSI 序列后的可见列数
    if vw == 0 { return 1 }               // 空行占 1 物理行
    return (vw + width - 1) / width       // 向上取整
}

公式简单。麻烦在 visibleWidth——要先剥离 ANSI 序列(不占宽度但占字节),然后对每个 Unicode scalar 判定宽度。scalarIsWide 硬编码了 98 个 Unicode 区间,覆盖 CJK、假名、韩文、全角标点、emoji。这不是规范性的,是经验性的——Unicode 每年更新,新 emoji 不断加入,各终端对“模糊宽度“字符的处理各异。所有按 scalar 判定宽度的实现,在 ZWJ 序列(如 👨‍💻)和 VS16 变体选择符上都会算错——这是行业级的共同盲区。


六、全量重绘 vs 增量 diff

6.1 两种策略

ForgeLoopTUI 提供两种 RenderStrategy:

legacyAbsolute:  清屏归位 ESC[2J ESC[H + 逐行输出全部内容
inlineAnchor:    基于上一帧缓存做增量 diff(默认)

legacyAbsolute 永远正确但大帧闪烁明显。

inlineAnchor 的决策链:

首帧(无历史)?      → 直接输出全部内容
帧高度超过终端高度? → 退化为 legacyAbsolute
两帧完全相同?       → 零渲染(仅调整光标位置)
否则                → 计算差异 → 回退 → 清除尾部 → 重写尾部

6.2 diff 算法与取舍

private func firstDifferenceIndex(lhs: [String], rhs: [String]) -> Int? {
    let minCount = min(lhs.count, rhs.count)
    for index in 0..<minCount where lhs[index] != rhs[index] {
        return index
    }
    return lhs.count == rhs.count ? nil : minCount
}

逐行字符串比较,O(min(m,n))。没用 Myers diff。原因很实际:

  • 流式场景里,增量大多来自尾部增长或局部修改——逐行比较够用
  • 物理行计算才是真正的瓶颈——每行都要跑 visibleWidth + physicalRows
  • 终端窗口通常 <100 行——引入一个 diff 库为这个规模不值得

这是 TUI 渲染和 GUI 渲染的一个微妙区别:GUI 里 diff 是数据结构层面的(两个 view tree 的差异),TUI 里 diff 是物理坐标计算——瓶颈不在算法,在每行的宽度判定。


七、帧状态记忆

增量 diff 的前提是记住上一帧。TUI 类维护了 8 个关键字段:

previousLines            // 完整上一帧内容(用于逐行 diff)
lastFramePhysicalRows    // 上一帧总物理行数(用于光标回退计算)
lastCursorAnchored       // 上一帧是否锚定了光标
lastCursorOffset         // 上一帧的光标列偏移

committedLines           // committed 区上一帧(用于分区 diff)
lastCommittedPhysicalRows
previousLiveLines        // live 区上一帧
lastLivePhysicalRows

任何一个值算错——比如 physicalRows 差了 1——光标回退就差 1 行,整帧偏位。

7.1 光标放置的 undo 机制

Agent TUI 的典型布局:内容在上、输入框在下。渲染只能从上往下写——写完内容,光标在底部。然后需要把光标移到输入行。

ForgeLoopTUI 的做法:渲染内容后发出位移序列把光标移到目标位置,同时记录这次位移。下一帧渲染前,先发反向序列把光标移回“规范锚点“(最后一行末尾),再做 diff:

// 渲染后:记录位移
pendingPlacementUndoUp = upClamped
pendingPlacementUndoHorizontal = leftDelta

// 下一帧渲染前:先 undo
private func consumePlacementUndo() -> String {
    // 如有 marker 路径的精确 undo 序列则优先使用
    // 否则用相对位移恢复
}

两种光标模式:

  • .relative:ESC[nA]/ESC[nD],简单但 IME 候选窗可能干扰
  • .marker:物理行计算 + ESC[colG](CHA 绝对列),对中文输入法更可靠

7.2 记忆的脆弱性

所有帧状态基于一个未明文的假设:终端模拟器行为与预期一致。实际中不同终端对 wrap 行上的 ESC[nA] 行为并不一致,IME 候选窗会临时劫持光标,tmux buffer 可能与外部终端不同步,用户可能在两帧之间滚动。

任何一个假设被打破,diff 坐标就出错。没有全局恢复——等下一帧全量重绘来“洗屏“。


八、RenderLoop:帧合并调度器

TUI 渲染的频率上限没必要对齐显示器的 60Hz——终端 ANSI 输出是串行字节流,没有垂直同步的概念。ForgeLoopTUI 的 16ms tick 是合帧窗口的上限,不是目标帧率,也不是承诺值。

normal submit  → 更新 pendingFrame → 启动 16ms 定时器
normal submit  → 覆盖 pendingFrame(只保留最新,不渲染中间帧)
normal submit  → 覆盖 pendingFrame
                  ↓ 16ms 到
                flush: 渲染 pendingFrame(合并后只画一帧)
                  ↓ 队列空
                停止定时器(零 CPU 空转)

设计要点:

  • 合并,不丢状态:保留最新帧,不渲染中间帧。最终状态正确
  • 即时通道:priority: .immediate 跳过合并直接渲染(用户回车、工具调用完成时)
  • 空闲即停:flush 后队列空则停 timer,下次 submit 时重启

Agent 流式输出每秒几十个 token,每个都可能触发渲染。不合并 = 每秒几十次渲染触发,每次都产生一串 ANSI 输出;合并 = 每 16ms 至多渲染一次。


九、Live Budget Planner + commit/live 分区模型

这是整个 ForgeLoopTUI 架构里最重要的两个设计决策。

9.1 commit / live 分区

把一帧切成两块:

┌──────────────────────────┐
│  用户消息                 │  ← committed: 只追加,不改写
│  AI 已完成回复             │  ← committed: 只追加,不改写
│  ───────────────────     │
│  AI 流式输出中...         │  ← live: 每 tick 都可能变
│  工具调用: running...     │  ← live: 每 tick 都可能变
│  ───────────────────     │
│  > 用户输入 _            │  ← cursorPlacement 锚点
└──────────────────────────┘

收益三重:

  1. committed 跳过 diff:只做 count 比较,确认纯追加即可
  2. live 做 diff:只对少量行做 firstDifferenceIndex
  3. Live Budget Planner 只在 live 区生效:历史消息不受预算限制

快速路径在 committed 纯追加 + live 未变时触发:

if isPureAppend, liveDiff == nil {
    output += "\u{1B}[\(appendedCount)L"  // IL: 在当前行插入空行
    output += appendedLines.joined(separator: ttyNewline)
    output += live.joined(separator: ttyNewline)
}

ESC[nL](IL, Insert Lines)在当前行插入 n 行,当前行及以下顺移——不需要擦除和重写 live 区。硬约束:所有追加行必须是单物理行——代码块和表格几乎总是跨多个物理行,天然不满足这个约束,实际上走不到快速路径。

9.2 Live Budget Planner

Agent 流式输出无限增长,终端只有 24-50 行。LiveBudgetPlanner 做一件事:当 live 区超出预算,把旧的 live 行“沉降“到 committed 区。

渲染前:
  committed: [历史消息1, 历史消息2]
  live:      [流式行1, ..., 流式行50]  ← 超出预算

沉降后:
  committed: [历史消息1, 历史消息2, 流式行1, ..., 流式行10]
  live:      [流式行11, ..., 流式行50]

核心约束:不做部分行切割。一行要么在 live,要么全部沉降——因为部分切割意味着处理被切开的 Markdown 语法(表格边界、代码块边界),复杂度不可承受。

两种预算模式:.logicalLines(按逻辑行数限制)和 .physicalRows(按 wrap 后物理行限制,适合 Markdown)。算法是 O(n) 单次前缀累加:

let rowsPerLine = live.map { rows(for: $0) }
var remaining = rowsPerLine.reduce(0, +)
var settleCount = 0
while settleCount < maxSettle, remaining > budget {
    remaining -= rowsPerLine[settleCount]
    settleCount += 1
}

十、所有权模型:谁为已完成内容记账

本节为 2026.08 补记(同月二次修订)。本篇写完后我读了 Codex、Grok Build 的源码和 Claude Code 的发布产物,发现这里漏掉了一条重要的分界线,完整论述见第 11 篇,这里只补坐标系。

本篇从头到尾解剖的是一种特定范式:全屏自管——屏幕上每一行都归引擎管,committed 区虽然“只追加、不改写“,但只要还显示在屏幕上,就持续参与每帧的 diff 和 resize 重算,引擎为它持续记账。

另一种范式是 inline viewport:内容一旦在语义上定型(工具调用完成、回复结束),就立刻打印进终端的原生 scrollback,从此归终端管,引擎销账;引擎只维护底部一小块活区。但要特别说明:2026 年中的头部实现没有一家单边押注——Claude Code 和 Kimi Code 默认 inline(前者是内置 Ink fork,Messages.tsx 的 shouldRenderStatically() 决定一条消息何时“静态化“),Codex 和 Grok Build 默认反而是全屏(都内置 inline 降级路径,前者是 insert_history.rs,测试直接叫 vt100_live_commit)——四家全都把这条分界线做成了产品内部的开关。细节见第 11 篇。

两派的 live 区渲染是同构的——增量 diff、双缓冲、erase-rewrite 大家都做。分歧只在所有权:一个 block 完成之后,谁继续为它记账。全屏自管派换来任意可见历史的可改写性,代价是帧状态的持续脆弱;inline 派换来状态表面积的急剧缩小,代价是历史不可触碰(改写需求路由到模态查看器或导出)、resize 需要核平重印。完整的机制解剖和税单对照见第 11 篇。


十一、为什么框架教不会你这些

回到开头的问题。用 Bubble Tea 或 Ink 写一个 TUI,你不需要知道 ANSI 状态机怎么处理分片到达、不需要手动计算物理行、不需要自己维护 8 个帧状态字段。框架帮你做了这些——但框架的选择也定义了你的上限。

ForgeLoopTUI 走的路是渲染引擎级别的控制。它没有引入第三方 Markdown 库(手写逐字符 inline formatting 扫描),没有引入 Myers diff(终端窗口 <100 行不值得),没有依赖任何 UI 框架的组合模型。代价是代码量上去了,回报是:你可以精确控制每一帧的每一个 ANSI 字节。

哪种路更好?取决于你在什么约束下交付。但如果你要做的是 Agent TUI——流式输出、工具调用乱序返回、中途取消、thinking 展开/折叠——框架的通用抽象迟早会撞墙。第 4 篇会展示这些墙具体长什么样。


十二、从这里出发

TUI 的技术全景:在终端这片上世纪 70 年代末的地基上,用几十个 ANSI 转义序列构建增量 diff 渲染引擎。

困境面:字符网格没有声明式 API、没有 undo、物理行与逻辑行的认知错位让 diff 每一步都依赖 O(n) 的显式宽度计算、帧状态记忆脆弱到一次 resize 就能全盘失效。

优势面:语言无关、资源占用极低、跨平台零成本、管道可测试、tmux 断线恢复。GUI 并非做不到这些——只是要做到同等体验,工程成本和基础设施门槛高一个数量级。第一篇讲过信任模型,这一篇补充了另一面:CLI/TUI 给开发者 agent 提供了一个可以精确控制每一帧的渲染基座。这个基座不漂亮,但可靠。

下一篇讲 Markdown 在 TUI 上的流式渲染——降级映射、Stable Prefix Cache、表格 retreat 机制,以及为什么“流式期间要不要渲染 Markdown“是一个真正的工程难题。


本文所有技术细节基于 ForgeLoopTUI v1.2.0 的实际源码。Agent TUI 的另一条实现路径可参考 tiankonguse 的 Bubble Tea + Lipgloss 方案。两条路线的差别值得说透:组合框架(Bubble Tea + Lipgloss)给你 widget 树和声明式布局,渲染细节由框架代劳;手写 ANSI 状态机则要自己做物理行计算、增量 diff、合帧调度——也就是本篇从头到尾展示的这套东西。前者上手快,但上限由框架定义;后者代码量上去了,换来对每一帧每一个 ANSI 字节的精确控制。两条路线的深度差一个数量级。

Agent 时代的开发者界面(三):Markdown 在 TUI 上的流式地狱

系列第三篇。上一篇拆解了 TUI 渲染引擎的底层——ANSI 序列、字符网格、物理行计算、增量 diff。这一篇聚焦 Agent 场景里最让人头疼的问题之一:在只有字符的终端里,把流式输出的 Markdown 渲染成可读的样子。


一、Markdown 天生反感流式

Markdown 是 John Gruber 在 2004 年设计的,设计目标很明确:一种“写起来像纯文本、看起来像格式化文档“的轻量标记语言。它的解析模型是“写完 → 一次性解析 → 渲染“。

这里有一个结构性的矛盾:

Markdown 的设计前提:          Agent 的实际输出:
─────                         ─────
完整文档                      token-by-token 流式到达
闭合的块级结构                随时可能出现半个代码块
确定的语法树                  下一个 token 可能反转已解析的结构

举一个简单的例子。Agent 输出到一半时:

我们今天讨论三个要点:
1. TUI 的字符网格
2. Markdown

此时解析器会把这两行识别为有序列表的头部。但下一个 token 可能是:

不是唯一的选择

这行没有数字前缀——按 CommonMark 的 lazy continuation 规则,它会续接到最后一个打开的块上,也就是第二项的多行续文,不是新列表项也不是普通段落。但解析器在流式到达的那一刻根本不知道。

更致命的情况:

```swift        ← 代码块开始
let x = 1
```             ← 闭合了。但模型下一秒又吐出一行代码——
let y = 2        ← 它是新段落,还是代码?

严格说这不是 Markdown 的词法歧义——CommonMark 的规则是确定的。歧义来自流式本身:解析器每一帧都要对“目前已到达的内容“给出确定结论,而这个结论随时可能被后续 token 推翻。更典型的例子是更长的开 fence:四个反引号打开的块里,三连反引号是合法内容,一行 ``` 到底是内容还是闭合,要看到更多上下文才能判定——而“更多上下文“还没生成出来。


二、降级映射:把视觉语义翻译成字符

第 2 篇讲过,TUI 只有字符。Heading 用不了 24pt 字号,ForgeLoopTUI 的选择是符号前缀。这就是“降级映射“——把 Markdown 的视觉语义翻译成终端字符网格能表达的形式。

2.1 Heading

switch markerCount {
case 1: prefix = "█ "    // <h1>
case 2: prefix = "▓ "    // <h2>
case 3: prefix = "▶ "    // <h3>
case 4: prefix = "▹ "    // <h4>
case 5: prefix = "• "    // <h5>
default: prefix = "· "   // <h6>
}

六种前缀符号对应六级标题。“█“比”·“视觉重量大,读者自然把它解读为更高级别的标题。另一条路线是颜色和样式:glow 把所有标题统一加粗染蓝,层级仍靠 # 前缀数量区分(仅 H1 额外去掉 # 并加整行背景高亮);Claude Code 更省——标题仅加粗(H1 加下划线),无颜色、无层级区分。ForgeLoopTUI 走的是纯符号路线。

2.2 代码块

┌─ code swift
│ func main() {
│     print("hello")
│ }
└─ end code

Box-drawing 字符画出边框。语言标签放在起始行。算不上漂亮,但边界在任何终端里都清晰可见。另一条路线是背景色(glow 给代码块整体加背景色,delta 在 diff 场景用整行背景色),视觉区分度更好,代价是依赖终端的背景色渲染——box-drawing 不依赖任何颜色能力。

2.3 有序/无序列表

• 第一项
◦ 嵌套项
▪ 更深嵌套
▫ 再深一层

四个不同符号按嵌套层级循环。任务列表用 ☐ 和 ☑。

2.4 Blockquote

用 │ 前缀加左侧竖线。嵌套引用叠加前缀:│ │ 二级嵌套。

2.5 内联格式

MarkdownTUI 渲染ANSI 序列
`code`反色显示ESC[7m
**bold**粗体ESC[1m
*italic*斜体ESC[3m
~~strike~~删除线ESC[9m
[text](url)下划线文本 + 灰色 URLESC[4m / ESC[2m

斜体 ESC[3m 的兼容性不可靠——有的终端渲染成反色,有的直接忽略。

这里有一个值得注意的细节:applyInlineFormatting 不依赖任何 Markdown 解析库。它是手写的逐字符扫描,自己找 **、*、`、~~、[text](url) 的边界。不引入 cmark 或任何第三方依赖。原因很简单:终端里不需要 HTML 语义树,只需要知道“这两个星号之间加粗体 ANSI 序列“。

2.6 表格——最复杂的子系统

表格是整个 Markdown 引擎里代码量最大的部分,连实现带配置接近 400 行。终端里的表格有三种失败模式:

模式一:列太宽,终端放不下。 解决策略由 TableOverflowBehavior 控制:

compactThenTruncateThenDegrade(默认):
  1. 先挤压每列宽度(不低于 minColumnWidth = 6)
  2. 还不够 → 截断单元格内容(加截断符 "…")
  3. 仍然不够 → 退化:放弃 box-drawing,回退到原始 Markdown 文本

退化是最后手段——box-drawing 表格变成原始 | col1 | col2 | 格式,可读性下降但至少不溢出。

模式二:列被截得太狠,内容看不清。 autoReadable 策略在截断比例超过阈值时自动退化:

if truncatedCellRatio > 0.4  // 超过 40% 的单元格被截断
   || trimmedWidthRatio > 0.3  // 或总宽度被砍掉 30% 以上
{ return nil }  // 退化

每列被压到只剩 6 字符宽的表格比原始 Markdown 更难读,不如直接退化。

模式三:表格还在流式输入中。 这是最棘手的。Agent 输出到一半:

| Name | Value |
|------|-------|
| CPU  | 85%   |
| Mem  | ← 这里还在写

TableStreamingBehavior 控制行为:.monotonic 立即渲染所有已完成的行,.strict 等到表格块完全结束才渲染。但默认的 .monotonic 还有一个隐藏的防御逻辑——retreatToAvoidSplittingTrailingStreamingTable,这是第三节要讲的核心机制。

2.7 先决条件:宽度得算对

以上三种失败模式都建立在一个前提上:宽度算得对。而终端里的宽度不等于字符数——CJK 字符占两列,多数 emoji 占两列,ANSI 序列占零列。ForgeLoopTUI 的 DisplayWidth.visibleWidth 先剥掉 CSI 序列,再按 Unicode 区间判定宽字符计 2、其余计 1,表格的列宽求解和截断全部走这个函数。

它的局限也值得知道:判定是按单个 Unicode scalar 做的,不处理组合序列——ZWJ 序列(如 👨‍💻)和 VS16 变体选择符会被算错宽度,表格边框随之错位。这是所有按 scalar 判定宽度的实现的共同盲区,不独 ForgeLoopTUI;中文 Agent 场景里 CJK 是家常便饭,宽度问题比纯英文场景暴露得更频繁。


三、Stable Prefix Cache:在流式里偷懒

第 2 篇讲了 TUI 的增量 diff 机制——记住上一帧,只重绘变化部分。Markdown 渲染引擎也用了类似的思路,但不是 diff 行,而是缓存“稳定前缀“。

3.1 为什么只能在换行符处切分

public func render(text: String, isFinal: Bool) -> [String] {
    // 1. 如果新文本不以缓存的 stableSource 开头 → 缓存失效
    if !stableSource.isEmpty, !text.hasPrefix(stableSource) { reset() }

    // 2. 切掉已缓存的前缀,只处理新增部分
    let suffix = String(text.dropFirst(stableSource.count))
    let advance = stableAdvance(in: suffix, isFinal: isFinal)
    if advance > 0 {
        let stableDelta = String(suffix.prefix(advance))
        stableSource += stableDelta
        stableRendered += renderFully(text: stableDelta, isFinal: true)
    }

    // 3. 不稳定后缀(最后一行)每次重新渲染
    let unstable = String(text.dropFirst(stableSource.count))
    let unstableRendered = renderFully(text: unstable, isFinal: isFinal)
    return stableRendered + unstableRendered
}

stableAdvance 只在换行符处将文本标记为“稳定“。因为 Markdown 的块级结构——段落、标题、列表项、代码块边界——都以换行符分隔。一行没写完之前,你无法确定它是什么块级元素。

稳定区(已渲染,缓存)         不稳定区(每次重渲)
─────                         ─────
## 标题\n                      代码块开始\n
列表项1\n                      代码内容\n
列表项2\n                      正在输出的这行...

3.2 流式表格的稳定性陷阱

stableAdvance 有一个关键的防御机制。如果文本以换行符结尾,它会把到那个换行符为止的部分标记为稳定——但如果这个换行符恰好在一张未完成的流式表格中间呢?

| Name | Value |
|------|-------|
| CPU  | 85%   |          ← 这行以换行结尾,会被标记为稳定
| Mem  | 正在输出...        ← 没有换行,不稳定

如果第三行被标记为稳定(因为它以换行符结尾),渲染后的 box-drawing 表格会包含底部边框 └─┴─┘。下一帧第四行到了,需要加一行——但第三行已经“稳定“了,底部边框被锁死在缓存里。

retreatToAvoidSplittingTrailingStreamingTable 就是这个防御(节选自源码,部分局部变量定义省略):

private func retreatToAvoidSplittingTrailingStreamingTable(
    lines: [String], remainder: String
) -> Int? {
    // 如果剩余部分以 "|" 开头 → 可能是不完整表格的续行
    guard trimmedRemainder.hasPrefix("|") else { return nil }

    // 从尾部向上扫描,找到完整的表格头+分隔行
    // 然后退回到表格开始之前
    for start in stride(from: end - 2, through: 0, by: -1) {
        guard let headerCells = parseTableCells(lines[start]),
              let divider = parseDividerCells(lines[start + 1]),
              divider.count == headerCells.count else { continue }

        // 验证所有数据行列数匹配
        var allRowsMatch = true
        for rowIndex in (start + 2)...end {
            guard let rowCells = parseTableCells(lines[rowIndex]),
                  rowCells.count == headerCells.count else {
                allRowsMatch = false; break
            }
        }
        if allRowsMatch {
            // 找到完整表格 → 回退到表格头之前,整个表格保持不稳定
            return retreat
        }
    }
    return nil
}

逻辑:如果下一个 token 以 | 开头,检测候选文本的末尾是否构成一张完整的表格(表头 + 分隔行 + 至少一行数据)。如果是,整个表格都不标记为稳定——等表格块彻底结束后才一次性缓存。

3.3 盲区:代码块没有同等的防御

表格有 retreat 机制,代码块没有。一个未闭合的代码块可能被切进稳定区,而 renderFully 每次调用都从 inCodeFence = false 重新开始,fence 状态不会被记录。后果是连锁的:

第一帧: "```swift\nlet x = 1\n"
  → 全部进入稳定区,渲染为:
    ┌─ code swift
    │ let x = 1

第二帧: "let y = 2\n"
  → 落在不稳定区,但 inCodeFence 状态已丢失
  → 被当成普通段落渲染,没有 │ 前缀

第三帧: "```\n"(真正的闭合 fence)
  → 不稳定区把它误判为开 fence
  → 渲染成一个新的 "┌─ code"

这是 v1.2.0 及之前版本的一个真实盲区。修复思路和表格一致——retreat 时检测候选末尾是否处于未闭合的代码块内,是则回退到 fence 开始之前。该修复(retreatToAvoidSplittingUnclosedCodeFence)已在 v1.2.1 合入:fence retreat 先于表格 retreat 执行(未闭合 fence 之内的表格状行不是真表格),fence 判定复用 isCodeFenceDelimiter,与 renderFully 的状态切换语义保持一致。在 v1.2.0 上,规避方案见第四节的路线二。

3.4 65K 字符上限

private let maxStableSourceChars = 65_536

if stableSource.count > maxStableSourceChars { reset() }

稳定缓存不能无限增长。一次长对话的流式输出可能超过几万个字符。65K 是折中值:超出后 reset,下一帧把全部内容重新渲染一次,重新开始缓存。


四、流式期间到底要不要渲染 Markdown

第 2 篇和第 7 篇(GUI 部分)都会涉及这个问题,值得把账算清楚。

Agent 流式输出时,每秒几十个 token 到达。即使有 Stable Prefix Cache,每次渲染仍然需要:

  1. 跑 stableAdvance——扫描候选文本,检测是否是流式表格(可能 O(n) 回退扫描)
  2. 渲染不稳定后缀——即使只有一行,也要跑完整的管线:代码块检测 → 表格检测 → structured line 检测 → inline formatting

面对这笔成本,工程上有两条路线:

路线一:全程渲染 Markdown,用缓存摊薄成本。 ForgeLoopTUI 的实际选择——TranscriptRenderer 持有一个 StreamingMarkdownEngine,流式和结束走同一个引擎,靠 Stable Prefix Cache 保证每帧只处理增量。代价是第三节的防御逻辑(以及 v1.2.0 之前存在的代码块盲区)。

路线二:流式期间纯文本,结束后一次性切换。 Markdown 在流式期间提供的是“好看“而不是“必要“——代码、列表以纯文本展示,可读性损失很小。等流式结束,所有块级结构闭合,解析器不需要做任何防御性猜测,一次完整渲染搞定。这条路同时绕开了代码块盲区。

ForgeLoopTUI 为路线二保留了 PlainTextMarkdownEngine:

public func render(text: String, isFinal: Bool) -> [String] {
    guard !text.isEmpty else { return [] }
    return text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
}

它就是 split("\n")——零解析开销,零防御逻辑,零缓存状态。接入方完全可以在流式期间注入它,isFinal 时再切回 StreamingMarkdownEngine。

两条路线没有对错,是纯工程权衡。中间态也存在——做一个“轻量 Markdown 引擎“,只做 inline formatting 和代码块检测,跳过表格和 blockquote,在流式期间保留最基本的可读性。


五、代码块检测的边界

ForgeLoopTUI 的代码块检测不依赖完整 Markdown 解析——先去掉行首尾空白,再检查是否以三个或以上的 ` 或 ~ 开头:

private func isCodeFenceDelimiter(_ line: String) -> Bool {
    let trimmed = line.trimmingCharacters(in: .whitespaces)
    guard let first = trimmed.first, first == "`" || first == "~" else { return false }
    let run = trimmed.prefix(while: { $0 == first })
    return run.count >= 3
}

这和 CommonMark 规范有两个微妙区别。其一,CommonMark 要求闭合 fence 的长度不小于开 fence——三个反引号打开,三个或更多才能关闭;四个反引号打开的块里,三连反引号只是内容。ForgeLoopTUI 不检查长度匹配,只要看到三连反引号就切换 in-code-fence 状态。其二,先 trim 意味着 CommonMark“fence 最多允许 3 格缩进“的规则被丢弃——缩进 4 格以上的行若以三连反引号开头,会被误判为 fence。

这是一个有意的简化。在 Agent 流式场景下,代码块的内容来自 LLM 输出,不太会出现“四个反引号打开、三个反引号在内容里“的边界情况。而且实现正确的分隔符匹配需要回溯——代码块可能在几十行之前打开——与 Stable Prefix Cache 的假设冲突。


六、从这里出发

TUI 上 Markdown 流式渲染的核心矛盾可以总结为三句话:

  1. Markdown 是“写完再解析“的语言,Agent 是“边想边输出“的过程。 两者的设计哲学根本冲突。
  2. 终端只有字符,Markdown 的视觉语义必须降级映射。 降级不等于功能缺失——true color 语法高亮和 OSC 8 可点击超链接在终端里都可行,成本是手写 tokenizer 而非调库。
  3. 流式渲染 Markdown 的成本可以靠 Stable Prefix Cache 摊薄,但防御逻辑和盲区是必要之恶。 纯文本 fallback(流式期间跳过 Markdown、结束再切换)是更省的选择。

下一篇讲 Agent TUI 最核心的难题——流式输出、工具调用、thinking 展开后,这些问题如何从“难“变成“几近失控“。第 1 篇提过一个判断:Agent 的真实输出不是 1D 的。下一篇会把这句话完整展开。


本文所有技术细节基于 ForgeLoopTUI v1.2.0 的实际源码。§3.3 的代码块盲区已在 v1.2.1 修复(retreatToAvoidSplittingUnclosedCodeFence),正文保留该盲区的完整分析作为案例。

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 及更早版本中不存在。

Agent 时代的开发者界面(五):中场——Event Sourcing,Agent UI 的底层模型

系列第五篇,正篇的中场。前三篇(02–04)完成了 TUI 侧的完整深潜——渲染引擎、Markdown 流式、事件树展平。这一篇不继续往前冲,而是往上走一层:这三篇暴露的所有挣扎,到底有没有一个统一的理论解释?如果有,它是什么?


一、中场为什么有必要

回顾前三篇的核心矛盾:

  • 第 2 篇:终端没有 undo,增量 diff 依赖 8 个帧状态字段,一次 resize 全盘失效。为什么 TUI 的渲染正确性一定要绑在这么脆弱的状态上?
  • 第 3 篇:Markdown 是“写完再解析“的语言,Agent 是“边想边输出“的过程。Stable Prefix Cache 有代码块盲区,表格需要 retreat 机制。为什么这些防御逻辑一个都不能少?
  • 第 4 篇:Agent 输出是一棵树,工具调用乱序到达,thinking 中途穿插。稳定槽位队列 + shiftIndices 偏移追踪 + 有上限的 FIFO 通知队列——为什么渲染器要维护这么多索引?

答案藏在一个更根本的问题里:

Agent 的执行过程本身不是任何 UI 的“内部状态“。它是一组独立于界面的、按时间顺序发生的事件。TUI 的困境——脆弱的状态记忆、防御逻辑、索引追踪——并不来自“终端难用“,而是来自“把事件树强行投影成线性输出“这个投影操作本身的结构性代价。

换一个投影方式(比如 GUI),这些代价并不会消失——它们会以不同的形态重新出现。


二、Agent 的执行本质:一条事件流

如果你把 Agent 的一次完整执行记录下来,去掉所有 UI 相关的渲染细节,底层是什么?

type AgentEvent =
  | { type: "message"; role: "user" | "agent"; content: string }
  | { type: "tool.started"; toolCallId: string; tool: string; args: unknown }
  | { type: "tool.output"; toolCallId: string; chunk: string }
  | { type: "tool.finished"; toolCallId: string; status: "ok" | "error" }
  | { type: "file.changed"; path: string; diff: string }
  | { type: "permission.requested"; requestId: string; risk: RiskLevel }
  | { type: "permission.resolved"; requestId: string; approved: boolean }
  | { type: "task.plan.updated"; items: PlanItem[] }
  | { type: "validation.started"; command: string }
  | { type: "validation.finished"; result: ValidationResult }
  | { type: "thinking"; content: string; isFinal: boolean }
  | { type: "notification"; text: string }

这是一条 append-only 的事件流。Agent 的每一次推理、每一个工具调用、每一次权限请求、每一个文件变更,都只是这条流上的一个新事件。

关键洞察:这条事件流独立于任何 UI 而存在。 无论你用 CLI、TUI、GUI、IDE 还是 Web,底层都是同一件事——Agent 在做它的工作,产生事件。UI 的职责只是把这些事件投影到用户的感知空间。

这就是 Event Sourcing 在 Agent 领域的应用:Agent Runtime 是事件生产者,各种 UI 是事件消费者。不同 UI 是同一事件流的不同投影(projection)。


三、事件的三层分类

上面列的那些事件类型可以自然地分成三层:

层级事件类型特点
执行流tool.started / tool.finished / validation.* / task.plan.updated时间线核心,所有 UI 都必须处理
副作用流file.changed / tool.output可批量、可合并、可延迟投影
交互流permission.requested / permission.resolved阻塞型——需要 UI 在限定时间内给出响应

这三层只覆盖“执行相关“的事件。message、thinking、notification 不在其中——它们是内容流(对话内容、推理过程、系统提示),同样以事件形式被各端投影,但不参与执行流/副作用流/交互流的划分。

这个分类的价值不在于学术上是否严谨,而在于它解释了为什么不同 UI 对“同一套事件“的消费策略如此不同。

执行流是所有 UI 都要处理的基线。CLI 把它展平成线性文本,GUI 把它映射成卡片和状态动画。

副作用流在不同 UI 上的处理成本差异最大。TUI 里 file.changed 就是一行路径字符串;GUI 里它需要一个 diff 审查面板——并排对比、行级高亮、部分采纳/拒绝。同一个事件,投影成本差一个数量级。

交互流是最有意思的。CLI 里 permission.requested 是阻塞式 [y/n]——输入循环停下来,进程阻塞在 stdin 上等用户输入。GUI 里同一个事件可以是非阻塞 toast + badge counter——用户喝咖啡回来再处理。事件类型相同,但 UI 和事件之间的响应契约完全不同。 这就是为什么“先稳定事件协议,再谈 GUI 复杂交互“不是一句空话——如果你在事件协议里没有区分“需要即时响应“和“可以延迟处理“,GUI 的优势就无从发挥。


四、两种极端说法的共同盲区

当前关于 Agent UI 的讨论里,有两种极端的说法。

说法一:CLI/TUI 只是早期原型,GUI 必然到来。 这个观点假设 CLI/TUI 和 GUI 是线性进化关系——蛋 → 幼虫 → 成虫。但用 event-sourcing 的框架看,这不是进化,是共存。SSH 到远程服务器跑 agent、在 tmux 里恢复一个跑了 3 小时的会话——这些场景里 CLI/TUI 不是“过渡方案“,是最优投影。GUI 在 diff 审查和多模态展示上有结构性优势,但这不是“替代“,是“分工“。

再往下推一层:这个说法还隐含一个成本假设——GUI 的投影成本会随着框架成熟趋近于零,所以“替代“只是时间问题。但 Agent GUI 的成本大头不在“画界面“,而在两个媒介的基线差异带来的子系统数量:diff 审查面板、权限弹窗、多模态附件,每一个都是独立子系统(第 6 篇会逐一拆解)。这个成本由 Agent 场景的需求决定,不随框架成熟收敛。

说法二:GUI Agent 方向可能从根本上就是错的,Agent 就应该走 CLI 原生路线。 这是港大 CLI-Anything 项目(2026.06)的立场。这个说法正确的一面是:为 Agent 设计结构化接口、输出 JSON 而非像素,确实比让 Agent 模仿人类操作 GUI 更可靠。但它错误的一面是:把“Agent 的核心接口应该是结构化的“和“用户界面应该全是 CLI“混为一谈了。event-sourcing 框架下,Agent 核心走结构化协议,用户界面走多端投影——这两件事不互斥。

再往下推一层:CLI-Anything 回答的是“Agent 如何可靠地操作软件“——这是接口层的问题,结构化协议确实是正确答案。但“人如何可靠地监督 Agent“是另一个问题——审查 diff、批准权限、审计历史,这些事情的载体是视觉界面,不是 JSON。把接口层的正确结论外推到界面层,就是这个说法越界的地方。

两种说法的共同盲区:把 UI 当成了 Agent 的本体,而不是 Agent 的投影。


五、从 ForgeLoopTUI 的 CoreRenderEvent 到 GUI 的 diffable snapshot

第 4 篇详细拆解了 ForgeLoopTUI 的 CoreRenderEvent 枚举和 TranscriptRenderer 的索引追踪系统。回头看这套设计:

case operationStart(id: "A", header: "...", status: "...")
// → pendingTools.append((id: "A", lineIndex: 42))

case operationEnd(id: "A", isError: false, result: "...")
// → 定位 lineIndex 42,替换状态行
// → shiftIndices 偏移后续所有引用

pendingTools 本质上是一个在内存中维护的事件关联索引。它做的事就是:把分散到达的 operationStart 和 operationEnd 重新配对,确保渲染结果在视觉上是连续的。

现在把这个思路映射到 GUI。在 UICollectionView 或者 LazyColumn 的世界里,你不需要手写 shiftIndices。你需要的是:

event stream → diffable data source → snapshot → cell 复用

operationStart → 插入一个新的 tool card cell(pending 状态)。operationEnd → 找到对应 cell,更新为 done/error 状态,触发局部重绘。框架帮你处理索引偏移——这是 UICollectionViewDiffableDataSource 的内置能力。

同一个数据结构,不同的投影成本。TUI 要手写 200 行索引追踪;GUI 框架内置。但反过来,GUI 要为“tool card 的 pending → running → done 状态动画“写一套完整的 cell 生命周期管理——TUI 只需要换一行文本。

这套 cell 生命周期管理拆开看并不轻松。状态迁移要走批量更新才能保证动画连贯,不能这个 cell 已经翻牌、那个 cell 还在旋转;内容高度随流式更新不断变化,每条事件都可能触发一次局部 relayout;cell 被回收复用时必须完整重置——进度动画、展开状态、错误样式,漏重置一个,就是“新消息显示上一条的旋转图标“这种经典 bug。TUI 侧的对应物是第 2 篇那 8 个帧状态字段,集中在一处、错一处全盘可见;GUI 侧的对应物是分散在每个 cell 里的状态机,单点错、局部错,但排查时要面对的是几十种 cell 类型各自的生命周期。

投影成本不消失,只是换了形态。


六、append-only event log 的长期价值

事件流不只是渲染层的技术选型。它在 Agent 产品的几个关键维度上都有结构性的价值:

恢复。 用户关闭客户端、Agent 继续执行。重连时,客户端从 event log 的最后一条已知事件开始消费,所有状态重建——不需要存储“当时的 UI 状态“,只需要回放事件。第 2 篇讲的帧状态记忆(8 个字段、resize 全盘失效)之所以脆弱,正是因为 TUI 存的是“渲染结果“而不是“事件源头“。

审计。 想知道 Agent 昨天在哪个文件上做了什么改动?查 event log——file.changed 事件带时间戳、路径和 diff。不需要翻聊天记录。

协作。 两个用户看同一个 Agent 会话——Web 端看到富文本 diff 面板,IDE 端看到行级采纳/拒绝按钮。同一个事件流,两个投影。不需要为每个端单独维护状态。

回放。 调试 Agent 行为时,回放 event log 比复现对话快得多。

已有开源实践在走这条路——Zacp 通过 ACP 协议把多个 CLI Agent 的事件流转发到 WebUI,是多端投影的一个雏形。细节第 9 篇展开。


七、这个框架不能解决什么

Event Sourcing 解释了结构,但有两个问题它不能替你回答。

第一,事件的粒度应该多细? 太粗——file.changed 包含整个文件的 diff,GUI 上的 diff 面板没法做行级高亮。太细——每个 token 都是一个事件,事件量爆炸,storage 和 replay 成本不可接受。粒度的答案不来自理论,来自具体 UI 的交互需求。

第二,交互流的响应契约怎么设计? permission.requested 是阻塞还是非阻塞?超时时间多少?哪些权限可以批量预批,哪些必须逐个确认?这些不是事件类型的字段问题,是产品设计问题。Event Sourcing 能告诉你“这里需要一个 permission 事件“,但不能告诉你“这个事件应该在 UI 上存活多久“。


八、中场小结

这篇文章试图证明一件事:

Agent UI 的问题,本质不是 CLI/TUI 和 GUI 的形态之争,而是如何为一条不确定、乱序、可中断的事件流设计多端投影。

前三篇(02–04)的 TUI 深潜,展示的是“线性投影“这种特定投影方式的完整成本。接下来三篇(06–08)转向 GUI,展示同一套事件在像素世界里的投影成本——同样的结构性矛盾,不同的表现形式。

一条事件流,多种投影。


下一篇

第 6 篇:为什么做一个“不错“的 Agent GUI 这么难——从“两个媒介的基线不同“切入,拆解 FlowDown 的七个子系统,看同样的 Agent 事件在 GUI 世界需要多少额外的基础设施。


本文的 event-sourcing 框架部分受 ForgeLoopTUI 的 CoreRenderEvent 设计和 Zacp 的 ACP 多 Agent WebUI 架构启发。知乎讨论参考:为什么现在大多 Code Agent 的主形态是 CLI/TUI?。港大 CLI-Anything 项目:https://github.com/HKUDS/CLI-Anything。

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)聊天界面主体消息列表、流式动画、工具调用展示、语音输入
ChatClientKitOpenAI 兼容 API 客户端SSE 流式解析、请求管理、重试逻辑
ColorfulX动画颜色/渐变渲染打字机效果中的背景动画、品牌色渐变
ListViewKit高性能列表视图diffable data source、cell 复用、动态高度缓存
GlyphixTextFx文字特效打字机逐字 reveal 效果
AlertController弹窗系统工具确认弹窗、MCP 授权弹窗

ChatClientKit 是两边共担的协议层成本——TUI 同样需要 SSE 解析和请求管理。真正拉开差距的是其余六个 UI 子系统。

对照 TUI:

GUI 子系统TUI 对应成本差异
MarkdownViewStreamingMarkdownEngine(~800 行 Swift)TUI 略省(~800 行 vs 8500 行,布局成本在渲染引擎里另算)——第 3 篇和第 7 篇各自展开
ListViewKitTranscriptBuffer(一个 [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。

Agent 时代的开发者界面(七):GUI 上的 Markdown 流式渲染——全量重建与工程折中

系列第七篇。第 3 篇拆解了 TUI 上的 Markdown 流式渲染——降级映射、Stable Prefix Cache、表格 retreat、代码块盲区。这一篇是同一面问题的镜像:在拥有像素、字体引擎和 GPU 合成器的 GUI 世界里,Markdown 流式渲染为什么仍然是一个“打字机效果“就吃掉 60% CPU 的难题。


一、打字机效果 ≠ 增量渲染

用过 ChatGPT、Claude、FlowDown 的人一定熟悉打字机效果——AI 回复逐字出现。但这是视觉错觉。

真实情况:数据层是增量的,渲染层不是。

网络 SSE chunk → BalancedEmitter(增量吐出字符)
  → message.document += newText               // 全文拼接
  → MarkdownParser.parse(fullContent)          // ← 全量 AST 解析
  → TextBuilder.build()                        // ← 全量重建 NSAttributedString
  → textView.attributedText = doc              // ← CoreText 全量布局

每一步 O(n)。n 随文本增长而变大。以下是 FlowDown 在一组本地实测里的 3000 字回复数据,反映的是特定设备和构建配置下的趋势:

文本量解析构建布局刷新频率CPU
~500 字轻轻轻20 Hz低
~1500 字中中中15 Hz中
~3000 字重重重9 Hz~60%
5000+ 字很重很重很重3 Hz高

应用已经做了降频处理——文本越长刷新越慢。但这只降低了“重做“频率,没降低单次“重做“的成本。3000 字时每秒仍有 9 次全量管线触发。

打字机效果是 BalancedEmitter 增量吐出字符制造的数据层假象。渲染层每次都在重做整个文档。


二、三层管线拆解

从 LLM token 到屏幕像素,经过六个阶段:

LLM token 流
  → ① 传输:SSE chunk 到达
  → ② 拼接:chunk 拼成字符串,节流控制
  → ③ 检测:判断当前文本是否"可解析"(大部分实现没有这层)
  → ④ 解析:Markdown → AST
  → ⑤ 构建:AST → NSAttributedString
  → ⑥ 布局:NSAttributedString → 像素

④⑤⑥ 深度耦合。问题不能靠单独替换某一层解决。

解析层——最根本的限制

以 cmark 为例(Swift 侧最主流的 Markdown 解析库,被 FlowDown/MarkdownView、Down 等使用):

cmark_parser_new()    → 创建 parser
cmark_parser_feed()   → 喂入整个字符串
cmark_parser_finish() → 结束解析,产出 AST
cmark_parser_free()   → 销毁 parser

没有 pause(),没有 resume(),没有 ast_diff(old, new)。每次新 chunk 到达都必须走完整的 create → feed → finish → free 流程。

构建层

FlowDown 的 TextBuilder 源码里有一行:

assert(!previouslyBuilt, "TextBuilder can only be built once.")

整个 builder 假设输入是一份完整的解析结果。一次性产出完整的 NSMutableAttributedString,遍历所有 block node 做属性拼接。NSMutableAttributedString 本身有 append(_:)——但这条 builder 管线的设计假设不支持增量。

布局层

textView.attributedText = doc 之后,整段富文本重新进入文本系统计算。富文本布局比纯文本贵 50-100 倍——同样的 3000 字,纯文本布局 <1ms,富文本 20-60ms。差距来自字形查找(6+ 种字体 face)、CJK 字体回退链、换行断点评估、内存分配风暴。

2.1 O(n) 为什么这么重——常数因子拆解

O(n) 的大 O 记法掩盖了常数因子。以 iPhone 上 3000 字富文本 NSAttributedString(包含代码块、标题、加粗、链接、表格等多种样式组合)为例,一次 CoreText 布局的实际开销拆开看:

字形生成。 纯文本布局是 trivial 的——一个字体 face,逐字符排版。但 Markdown 渲染产出的是高度异构的 NSAttributedString:正文用 .systemFont,加粗/斜体用不同 weight,代码块用等宽字体(Menlo 或 SF Mono),标题用不同 size(H1-H6 至少 2-3 种),链接可能换 foregroundColor,emoji 触发 Apple Color Emoji 字体回退路径。每个字符的属性变更都可能触发一次新的字体 face 查找。3000 字符 × 5-6 种字体 face,加上 CJK 字符的字体回退链(CJK 字形在 systemFont 中不完整时需回退到专用中文字体),字形查找本身就是显著的 CPU 开销。

换行计算。 CoreText 的 line breaker 对每一行都要做宽度测量(候选断点位置的累积字符宽度)、断点选择(在允许的断点中选最优,考虑连字符、标点禁则)、中文特殊规则(行首禁则——。,、不能出现在行首,行末溢出——标点可以略微突出边界)。一个 3000 字的段落,假定平均每行 30 个字符、总共 100 行,断点评估的量级远比“扫描一遍字符串“大得多。

内存分配与 ARC 开销。 这是最容易被忽略的一项。每个 tick 的完整路径:cmark_parser_new + cmark_node_new × N + cmark_parser_free(malloc/free AST 节点树)→ NSMutableAttributedString.append() × M 个 block(每个 append 可能触发属性字典 NSDictionary 分配)→ CTFramesetterCreateWithAttributedString(内部分配 CTTypesetter + CTLine × 100 行 + 字形缓存)。9 次/秒 × 3000 字:大量短暂存活的对象在单次 runloop iteration 中创建和丢弃,ARC 的 retain/release 在这些对象间高频触发,autorelease pool 在 runloop 末尾 drain 时集中释放,产生额外 CPU spike。

量化对比:

操作3000 字耗时(数量级)占比
cmark 解析~1-3ms~5%
TextBuilder 构建~5-15ms~15%
CoreText 布局~20-60ms~80%

同一段 3000 字的纯文本布局(单一字体、无属性切换):< 1ms。差距是 50-100 倍。真凶不是文本长度,是属性异构度 × 高频触发。

2.2 CoreText 全量 invalidate 的本质

CTFramesetter 的 API 设计假设输入的 NSAttributedString 是“完整的、不会增量变化的“。当你修改 attributedText 并重新 set 时:必须重新创建 framesetter → 必须重新对整个内容做排布 → 每行 line fragment 的位置都可能因为前面内容的变动而偏移。CoreText 内部没有“脏区域“标记机制——它不知道哪些行变了、哪些没变。

对比 CSS layout engine:浏览器里 element.appendChild(newParagraph) 只标记父节点为 “layout dirty”,然后只重排那个子树,页面大部分内容保持原位。而 CoreText 的 textView.attributedText = newAttrString——一切 invalidate,从头到尾全量重排。

Markdown 的块级结构更是放大了这个问题。列表嵌套深度、代码块边界、角标定义——这些跨段落上下文依赖意味着即使 CoreText 支持 dirty reflow,Markdown 解析层的全局依赖也会让“局部修改“扩散为“全局 dirty“。


三、路线 A:边输出边渲染(FlowDown 模式)

BalancedEmitter:节流发射器

actor BalancedEmitter {
    private var buffer: String = ""
    private var frequency: Int         // 发射次数/秒
    private var batchSize: Int         // 每次发射字符数

    func add(_ chunk: String) {
        buffer += chunk
        batchSize = max(1, Int(ceil(Double(buffer.count) / Double(frequency))))  // 动态
        dispatchLoopIfRequired()
    }
}

batchSize 是动态的——缓冲区积压越多,每次发射字符越多,自适应追赶。

emotionalDamage 降频

文本越长 → 渲染越重 → 主动降频:

var emotionalDamage = 0  // 累计字符数

if emotionalDamage >= 5000 {
    frequency = 3   // 3次/秒 — 极长文本
} else if emotionalDamage >= 2000 {
    frequency = 9   // 9次/秒
} else if emotionalDamage >= 1000 {
    frequency = 15  // 15次/秒
}
// 默认 20次/秒

设计思想:“宁可视觉上慢一点,也不能让 UI 掉帧。”

代价:O(n²) 从哪来

每次渲染是 O(n)。但流式场景下 O(n) 被执行了 O(n) 次:

tick  1: parse(50字)  + build(50字)  + layout(50字)
tick  2: parse(100字) + build(100字) + layout(100字)
...
tick 90: parse(3000字)+ build(3000字)+ layout(3000字)

总工作量 ≈ N²。降频让 t 不再严格正比于 N——但砍的是常数因子,复杂度阶没变。降频的收益集中在中等长度(1000-5000 字)——文本继续变长、频率触底(3Hz)之后,tick 数重新正比于 N,总工作量回归 N²。


四、路线 B:输出完毕再渲染

流式期间不做任何渲染,只把 chunk 拼进 buffer。流式结束后一次性做完整 Markdown 渲染。

流式中:buffer += chunk(字符串拼接,摊还 O(1) per chunk)
流式结束:一次完整 parse + build + layout

CPU 优势:管线只跑一次。代价:没有打字机效果——等了几秒后突然出现完整回复。适用于非实时对话场景(批量处理、后台任务)。


五、过渡方案:流式纯文本 + 结束切 Markdown

这是 ROI 最高的方案。

流式期间跳过④⑤⑥层,用纯文本展示。流式结束那一刻做一次完整 Markdown 渲染,切换成富文本。

流式中:纯文本渲染(无 parse/build 开销)
流式结束:一次性完整 Markdown 渲染

改动全在应用层——渲染入口加一个 if-else,不碰任何外部库。流式期间 Markdown 解析开销清零。

代价:流式过程中看到的是原始 Markdown 语法(**加粗**、# 标题),流式结束有一次 snap 切换。但流式过程中 Markdown 语法通常是未闭合的——**bold 没右半截、代码块没结尾 ```——当前方案下这些残缺语法的渲染结果也是错乱的。纯文本反而是干净的。


六、跨技术栈对比:谁解决了哪一层

解析层(④)所有人都站在同一条起跑线上——没有真正的增量 Markdown 解析。差距全在⑤⑥层。

解析层(④)构建层(⑤)布局层(⑥)
原生 CoreTextcmark 全量NSAttributedString 全量重建全量 invalidate
原生 TextKit 2cmark 全量NSTextContentStorage 增量追加NSTextLayoutManager 局部 invalidate
WebViewJS 全量 parseReact reconciliation → 最小 DOM 变更浏览器增量 reflow
React NativeJS 全量 parseReact reconciliation → 最小变更集Yoga 增量布局
LynxJS/C++ 全量 parse后台线程 diff → updateData 增量原生增量 + list 虚拟化

下面展开 WebView、RN 和 Lynx 三条路——它们各自的优势、代价和适用边界。

6.1 WebView:增量 reflow 是出厂能力

浏览器渲染引擎(Blink / WebKit)从 90 年代 Netscape 时代就在解决“网页被 JavaScript 不断修改“的问题,增量 reflow 是它的第一性能力,不是后加的特性。

当 JavaScript 向 DOM 追加新节点时(appendChild),渲染引擎只标记受影响子树为 dirty:Style Computation 只对新增节点做 selector matching;Layout 从 dirty node 向上找到最近的 block formatting context root,向下做局部重排,兄弟节点不受影响;Paint 只重绘 dirty region;Composite 直接复用未变动的 GPU texture 图层。

但 Markdown 解析仍在 JS 层——marked.js、markdown-it 每次仍然是全量 parse。所以实际做法分两类:

做法 A:innerHTML 全量替换。 每次新 chunk 到达,把整段 Markdown 重新转成 HTML 字符串,赋给 innerHTML。这条路径没有节点级复用——innerHTML 赋值的标准行为是解析整段 HTML 字符串、销毁旧子树、构建新子树,浏览器不在这条路径上做新旧 DOM diff,不存在“结构一致就复用节点“的优化。它的收益在重建之后:新子树挂上文档时,渲染引擎按正常的 dirty 传播做增量 reflow 和重绘,兄弟容器和页面其余部分不受影响;而且 HTML 解析本身是高度优化的 C++ 路径,比 JS 层的 reconcile 便宜得多。所以“全量“全在 DOM 构建层,并不全在渲染层。

做法 B:React reconciliation。 Vercel AI SDK 的默认做法——useChat 每收到一个新 chunk 就触发一次重渲染(可用 throttle 参数节流)→ React 对使用 messages 的组件(通常是整个消息列表)做 Virtual DOM diff → 找到最小 DOM 变更集 → 只 apply 这些变更到真实 DOM → 浏览器收到最小 DOM 变更 → 最小化 dirty reflow。注意这里出力气的是 React 而非 SDK 本身——SDK 不含 Markdown 渲染逻辑,官方做法是让用户自行接入 react-markdown。React 的 Virtual DOM diff 替代了原生方案中 NSAttributedString 全量重建(⑤层),浏览器增量 reflow 替代了 CoreText 全量重排(⑥层)。

代价:WKWebView 进程隔离,每个 WebContent 进程占独立内存空间——占用随页面内容波动很大(数十 MB 到数百 MB),且 iOS 对单个 WebContent 进程设有系统级内存上限(随设备内存缩放,iPhone 大致从数百 MB 到约 2GB,iPad 更高),超限该进程直接被 jetsam 杀掉,表现为 WebView 白屏;聊天列表中多个 cell 不能都用 WebView;字体渲染和系统不一致;无法复用 Dynamic Type / VoiceOver。

6.2 React Native:新架构去掉了 Bridge 瓶颈

RN 经典架构(Bridge 模式)下,JS Thread 做 React reconciliation → Shadow Thread 用 Yoga(C++)做 flexbox 增量布局 → Main Thread 通过 Bridge 接收 mutation list 做原生视图增量更新。每次新 chunk 产生的微小变更都要经过异步 Bridge 序列化/反序列化。

新架构(Fabric + JSI,0.68+ 引入,0.76 起默认启用)去掉了异步 Bridge:JS 通过 JSI 直接同步调用 C++ 函数;Fabric 的 Shadow Tree 直接映射到原生 View Tree,支持优先级调度(高优更新可以打断低优更新)。对 Markdown 流式渲染的关键改进是:每次 chunk 产生的微小 DOM 变更不再经过异步 Bridge,布局计算可以在渲染帧空闲时执行,不阻塞主线程滚动。

但 RN 也不能做 Markdown 增量解析。它还有一个隐蔽问题:流式输出中,新 chunk 可能改变已有段落的结构(如代码块闭合后,之前被误判为代码块内容的东西需要重排),导致大量 key 失配,Reconciliation 退化为接近全量对比。

6.3 Lynx:引擎内置流式能力

Lynx(字节跳动出品)在两个关键设计上对 AI 流式场景特别友好:双线程架构(Main Thread + Background Thread)将网络流解析和业务逻辑处理放在非 UI 线程——token 到达 → 后台解析 → 生成 UI 更新 → 提交给主线程渲染,正好匹配 LLM SSE 流式数据的处理需求。fetchStream 在引擎层面提供了三端统一的流式资源接口,不像 RN 需要依赖第三方 SSE 库做桥接。updateData 支持从原生侧增量推送数据,不需要每次重刷整个模板。

Lynx 在流式渲染上的优势,本质上和 RN 新架构类似——后台线程处理流式数据和 UI diff,主线程只负责渲染。但 Lynx 把流式网络、模板更新、列表虚拟化做成了引擎内置能力,不需要开发者自己搭 pipe。

6.4 结论

不存在“增量 Markdown 解析“的跨平台魔法。 各技术栈只是在下游渲染架构上有不同程度的增量优化。WebView / RN / Lynx 的渲染架构天然更适应高频增量更新的冲击,原生 CoreText 在这方面是最脆弱的。如果你要做 Agent GUI 客户端,技术栈选型真正要考虑的不是“哪个能增量解析 Markdown“——不存在这样的方案——而是“谁的渲染引擎能更好地吸收高频增量更新的冲击“。


七、为什么移动端比桌面端严重得多

同一段 3000 字流式 Markdown,Mac 上 10-15% CPU,iPhone 飙到 60%。原因不在代码:

散热墙。 iPhone 是被动散热——持续高负载触发 thermal throttling 后 CPU 降频,单次 layout 从 30ms 升到 50ms,CPU 占用率反而更高,恶性循环。

CPU 微架构。 Mac M 系列芯片的共享 L2 与系统级缓存(SLC)更大,内存带宽更高。CoreText 布局阶段大量访问字形缓存——cache miss 时 iPhone 代价远高于 Mac。

WKWebView 成本。 WebView 的进程隔离在移动端是实打实的代价——每个 WebContent 进程占用独立内存空间,超限会被 jetsam 杀掉(直接白屏)。桌面端无所谓,移动端多 cell 场景是真实的稳定性风险。


八、四条优化路线的 ROI 排序

#方案改动面收益副作用
1继续降频一行参数CPU ~50-75%打字机变顿
2流式纯文本 + 结束切 MD1-2 个调用点流式 MD 开销清零流式期间看原始语法
3模块检测 + 分片渲染手写检测器 + 改造渲染管线视觉更好工程量大
4底层增量解析三个库架构重构理论上最优极高复杂度

方案 2 的 ROI 最高。方案 4 留给相关库的长期演进——或者留给 WebView/RN,它们的渲染架构天生更适应这个场景。最稀缺的工程能力不是写出复杂的方案,是判断一个方案不该做。


九、对照第 3 篇:同一个问题,两种介质

第 3 篇讲 TUI 上的 Markdown 流式渲染,困在字符网格和物理行计算里。这一篇讲 GUI 上的同一问题,困在解析器和 CoreText 的全量重建里。核心矛盾完全相同:Markdown 是“写完再解析“的语言,Agent 是“边想边输出“的过程。 两种介质只是在不同的层面与这个矛盾搏斗。

TUI 的策略:降级映射 + Stable Prefix Cache + 防御性 retreat。GUI 的策略:节流降频 + 纯文本 fallback + 模块检测。

没有哪个介质解决了这个问题。都只是在不同的约束下做了不同的折中。


十、下一篇

第 8 篇:权限 UX 与 Diff UX——Agent 真正的“主界面“。前几篇——TUI 三部曲和 GUI 两篇——都在讲“怎么渲染“,下一篇开始讲“渲染什么“——在 Agent 的所有输出中,哪两样东西最值得精心设计界面。


本文分析基于 FlowDown 源码(pin MarkdownView 3.9.1)和 ForgeLoopTUI 项目,性能数据来自 FlowDown 流式卡顿分析笔记。

Agent 时代的开发者界面(八):权限 UX 与 Diff UX——Agent 真正的“主界面“

系列第八篇。前七篇的大部分篇幅花在了“怎么渲染“上——ANSI 序列、Markdown 降级、事件树展平、CoreText 管线。这一篇换一个问题:在 Agent 产出的所有内容里,什么最值得精心设计界面?答案不是聊天框。


一、聊天框是 Agent 的“命令行“,不是它的“主界面“

用 Agent 的日常:你在聊天框里说“修一下这个 bug“,然后你盯着的不是自己的话,是两样东西——

  1. Agent 要做什么操作?读哪个文件?改哪一行?删什么东西?推不推送?(权限)
  2. Agent 做完之后,到底改了什么?有没有顺手改了不该改的?(diff)

聊天是指令和解释。权限是“执行前“的信任建立。diff 是“执行后“的信任验证。

如果你把 Agent 的 UI 设计成一个大聊天框 + 小字状态栏,你其实是在用“命令行“的范式做 Agent——所有信息都挤在一条线性流里。但 Agent 不是命令行工具。它是一个会主动发起操作、会产生副作用、需要你审计其产出的智能体。聊天框是交互入口,不是信息架构。


二、上半篇:权限 UX

2.1 权限不是 allow/deny 二选一

一个 Agent 请求“读取文件“,权限 UX 需要回答的问题远比 yes/no 多:

  • 为什么需要这个权限?因为用户要求“修复测试“,需要读测试失败的堆栈里引用的源文件。
  • 影响范围是什么?只读当前项目目录,不访问系统文件。
  • 具体命令是什么?cat src/auth/session.rs。
  • 可回滚吗?读操作天然安全,不产生副作用。

换成“删除文件“或“git push“:影响范围变了,风险变了,UI 也应该变。但不少 Agent 的权限 UX 只有一级——“allow / deny”——不区分风险等级。

2.2 风险维度的四层模型

风险维度操作示例建议授权粒度
只读读文件、搜索代码、查看 git log可批量自动批准(项目内)
写入(可回滚)编辑文件、git commit会话级授权
写入(难回滚)git push –force、数据库迁移每次确认
外部副作用网络请求、安装依赖、访问密钥每次确认 + 展示完整命令

这四层不是写在协议里的——它们来自操作本身的属性。一个好的权限 UX 在展示权限请求时,应该让用户一眼看到它在哪一层,而不是把“读取文件“和“git push –force“用同一个弹窗展示。

2.3 授权粒度:一次性 vs 会话 vs 项目 vs 全局

  • 一次性:仅此一次。默认对高风险操作。
  • 会话级:本次对话中同类操作自动通过。适合“编辑文件“——一个修复任务可能改 5 个文件,每次都弹窗是折磨。
  • 项目级:当前项目内同类操作自动通过。适合“读文件““搜索代码”——项目内读操作几乎没有风险。
  • 全局:永远自动通过。适合“显示当前时间““获取 git branch“等零风险操作。

就我观察到的产品而言,做满四层的很少,多数停在前面两层。后两层需要持久化授权策略:本地存储一份“信任规则“,Agent 在上报权限请求前先匹配规则。这不难实现——Claude Code 的 /permissions 和 settings 文件已经在做(会话、项目、全局三级),但把四层做全、且把规则管理做好的,仍然不多。

2.4 授权疲劳和过度打断的平衡

第 5 篇讲过 TUI 的 [y/n] 是阻塞式的——Agent 停下来等用户输入;同一篇还分析了 permission.requested 事件的交互流特性:它需要 UI 在限定时间内给出响应。

GUI 的优势在这里:同一个权限请求,可以做非阻塞的 toast + badge,用户喝咖啡回来再处理。但这也引入了一个新问题——如果用户不在,Agent 就一直等着?超时策略和默认行为(超时拒绝 vs 超时自动批准低风险操作)是权限 UX 里最需要产品判断力的部分,也是最容易被忽视的。

信任等级还要在界面上被“表达“出来,而不只是存在于配置里。常见的做法是把 2.2 的四层风险映射到视觉层级:只读操作用中性色、默认按钮聚焦“允许“;难回滚和外部副作用换警示色、去掉“总是允许“选项、默认聚焦“拒绝“——让用户在误触场景下落在安全的一侧。信任等级表达得好的界面,用户不读完整段描述也能判断“这个请求值不值得停下来细看“;表达得差的界面,所有请求长一个样,用户要么全部无脑点允许,要么全部停下来读——上面说的授权疲劳和过度打断,一半原因在这里。

补记(2026.09):本篇定稿后的第二天,这个取舍就有了新的行业答案。8 月 14 日起,Claude Code 的 auto mode 成为 Pro/Max/Team 计划的默认(3 月上线,此前只是可选项):一个 AI 分类器替用户批准常规操作、拦下不可逆和危险的命令。Anthropic 给出的依据是分类器能抓住约 80–89% 的危险命令,而审批疲劳的人类只有 13–14%。这等于在“逐条确认“和“无脑放行“之间插进了第三个审批者,把授权疲劳工程化地绕开了。但它同时造出一个新问题:程序员要的是可观察,分类器自己的判断过程却是一个新的黑箱——第 1 篇的信任模型在这里遇到了新的考题。

2.5 三种界面上的权限 UX 差异

CLI/TUIGUI DesktopWeb
展示一行文本 + [y/n]弹窗/内联卡片,含完整上下文同 GUI
阻塞性完全阻塞可非阻塞(toast + badge)取决于实现
批量批准y 或 a(全部)多选列表多选列表
信任规则持久化配置文件(如 Claude Code 的 .claude/settings.json)本地存储 + UI 管理面板服务端存储
离场处理不支持(用户离开 = Agent 挂起)超时策略 + 后台通知移动端推送

TUI 最简单但最脆弱——用户离开终端,Agent 就卡在权限请求上。GUI 有能力做得更好,但设计复杂度也更高。

2.6 操作路径长度:同一个权限请求,两种界面的真实差距

衡量 UI 好坏有一个直接的方法:不是看“展示多少信息“,而是看从看到请求到完成决策需要几步操作。

同一个场景——Agent 请求读取 ~/.ssh/config:

TUI 路径: 一行 [y/n] 提示 → 用户读文本判断风险 → 输入 y → 回车。3 步,信息密度低但是零 friction。适合:快速判断、低风险操作、用户在电脑前。

GUI 路径: 弹窗展示(工具名 + 文件路径 + 风险等级颜色 + 操作原因)→ 用户扫一眼颜色判断风险等级 → 点击“本次允许“。1 步操作,但前置了信息摄入时间。适合:高风险操作、用户需要完整上下文才能决策、用户可能不在电脑前(异步处理)。

关键洞察:两种界面在权限 UX 上的差距不是“GUI 展示更多信息所以更好“,而是“GUI 可以把线性操作变成空间决策“。 TUI 里你只能按顺序读——先读工具名、再读文件路径、再判断风险——因为终端只有一维空间。GUI 里颜色标记、图标、按钮位置同时进入视野,用户可以跳读。这是第 3–4 篇讲的“降级映射“在 UX 层的对应物:TUI 把空间信息压缩成线性文本,GUI 保留了空间维度。


三、下半篇:Diff UX

3.1 对 coding agent 来说,最终产物是 diff

你让 Agent 修一个 bug。它想了,搜了,改了,跑了测试。对话流里有 50 行 thinking、3 个工具调用、200 行日志。但最终你要看的东西只有一样:它到底改了哪些文件,每一处改动是否合理。

聊天是指令和解释。diff 才是交付物。

3.2 Agent 的 diff 和人类的 diff,审查需求不同

人类 PR review 关注:逻辑是否正确、风格是否一致、是否有隐患。

Agent 的 diff review 需要额外关注四件事:

  • 意图对齐:它改的是不是我想要它改的?它有没有自己“发明“一个需求?
  • 范围蔓延:有没有顺手改了不该改的——动了 lockfile、改了无关文件的 import、格式化了没碰过的函数?
  • 幻觉检测:有没有引入不存在的 API、错误的方法签名、凭空捏造的配置项?
  • 连锁影响:改 A 文件是因为改了 B 文件所以必须跟改,还是它随机碰的?

这意味着 Agent diff review 需要额外的上下文信息叠加——在 diff 旁边标注“这行是因为上一步搜索 UserSession 找到的引用“或“这个改动是基于测试失败输出中的第 3 行“。传统 diff view 不提供这些。

3.3 好的 Agent diff UX 应该怎么做

  • 按文件组织:树状文件列表 + 每个文件的变更行数。一眼看到“动了 3 个文件,其中 2 个是预期的,1 个是 lockfile——等等,它为什么动 lockfile?“
  • 按语义组织:同一类变更归为一组——“所有重命名”“所有格式化”“所有逻辑变更“分开展示。机械变更折叠起来快速扫过,审查精力集中在真正改了逻辑的 hunk 上。
  • 按风险标记:纯重构(变量重命名)→ 低风险绿色。逻辑变更 → 中风险黄色。引入新依赖、改动 API 签名 → 高风险红色。
  • 部分采纳/拒绝:用户可以 hunk 级别接受或回退,不是全量接受或全量拒绝。git add -p 的 GUI 版本。
  • 测试关联:每个变更旁边标注“这个改动使得测试 X 从失败变成通过“——建立 diff 和测试结果之间的因果关系。

按语义组织的价值在量大时最明显:一次修 10 个文件的 diff 里,可能 7 个文件只是 import 跟随调整,真正的决策点只有 3 个 hunk。把机械变更折叠成一组(“另外 7 个文件的 import 同步,共 42 行”),审查的认知负载从 O(文件数) 降到 O(决策点数)。这需要从 Agent 的事件流里提取意图信息——file.changed 事件本身不告诉你“这个改动是跟着哪个改动来的“,聚类是 diff UX 层要补的工作。

这些不是新发明——Code Review 工具(Gerrit、Reviewable、GitHub PR review)已经做了很多年。但 Agent 产出的 diff 有两个独特的约束:一是量可能很大(一次修 10 个文件的 bug),二是上下文信息需要从 Agent 的事件流里提取,不是从 git log 里提取。

3.4 Diff UX 是 CLI 和 GUI 分工最清晰的点

CLI/TUI:适合快速扫一眼。git diff 在终端里看 20 行变更,半秒判断“没问题“。适合小任务、小改动。

CLI/TUI 还有一个容易被低估的位置:过程展示。Agent 工作过程中,每产生一个 file.changed,终端里就实时追加一段 +/- 行——用户在 Agent 边干的时候边看 diff 逐步成形,发现方向不对可以立即打断,不用等它写完 10 个文件再审。GUI 的 diff 面板通常按“完成后集中审查“设计,实时流式展示反而是它的弱项——并排视图在中间态下频繁重排,阅读体验很差。过程展示用 CLI/TUI,集中审查用 GUI,这才是两种介质在 diff 上真正的分工。

GUI/IDE:适合深度审查。并排对比、行级高亮、hunk 级采纳/拒绝、文件树导航。适合大任务、多文件改动、需要仔细审计的场景。

这不是谁更好——是同一份 diff 在两种介质的投影各有优势。第 5 篇的 event-sourcing 框架在这里再次成立:file.changed 事件只有一个,但 TUI 投影(+/- 行文本)和 GUI 投影(并排审查面板)服务于不同阶段的用户需求。

3.5 信息提取效率:为什么 GUI diff 不只是“更好看“

第 3 篇讲过 TUI 的表格渲染——用 box-drawing 字符画边框(┌───┬───┐),在终端里它“像一个表格“,但它的本质是字符排列。当终端宽度不够时,需要复杂的截断策略。GUI 的原生表格:Auto Layout 自动处理宽度分配,不需要手动计算 cell 字符宽度。

同一个逻辑迁移到 diff:

TUI 的 git diff: 纯文本行,以 +/- 前缀区分新增和删除。你要逐行读才能建立变更的心智模型——“这里加了 3 行、删了 2 行、改了 1 个变量名”。信息密度高,但信息提取效率低——因为所有信息都在同一维度的文本流里,靠你的眼睛做解析。

GUI 的 diff 面板: 并排对比(旧在左、新在右),新增/删除/修改用不同背景色标记,行内差异用更深色高亮(比如变量名从 oldName 改成 newName,只高亮 old→new 两个词而不是整行),文件树显示每个文件的变更行数概要。你可以先扫文件树定位感兴趣的文件,再看并排对比理解具体变更,最后 hunk 级别决定采纳还是拒绝。

这不是“好看“,是信息架构的维度差异。TUI 把 diff 的二维结构(哪些文件 × 每个文件改了什么)压缩成了一维文本流。GUI 保留了二维结构,用户可以跳读。两者分别适合“快速确认“和“深度审计“两种认知模式。


四、收束:信任界面才是 Agent 的主界面

权限是“执行前“的信任建立。Diff 是“执行后“的信任验证。

把 Agent UI 设计成一个大聊天框 + 小字状态栏,等于把信任建立和信任验证都塞进了一条线性对话流里。用户需要自己从聊天的 100 行文本里挖出“它刚才请求了什么权限“和“它到底改了什么“。

更好的信息架构:聊天框是指令和解释的主通道。权限请求是侧栏的独立卡片。diff 是可展开的文件树面板。三者各有各的空间,不互相抢占。

这不是 GUI 做得“更漂亮“。这是在回答第 1 篇提出的那个根本需求:程序员需要的不是更智能的黑箱,而是可观察、可复现、可回滚的自动化。 权限 UX 做“可观察“,Diff UX 做“可复现 + 可回滚“。聊天框做“交互“。三个界面,同一个信任模型的三根柱子。


五、下一篇

第 9 篇:多端 Agent 架构——回到第 5 篇的 event-sourcing 框架,展开 CLI、TUI、IDE、Desktop、Web、Mobile 各自的定位和交互契约,外加 API 作为自动化集成入口。不再是“哪种 UI 更好“,而是“这些投影怎么共存在同一个 Agent Runtime 之上“。


本文的权限 UX 与 Diff UX 分析基于作者对主流 Agent 客户端的权限与 diff 交互观察。Diff UX 讨论基于对现有 Code Review 工具(GitHub PR、Gerrit、Reviewable)与 Agent 产出的结构性差异分析。

Agent 时代的开发者界面(九):多端 Agent 架构——CLI、TUI、IDE、Desktop、Web、Mobile

系列第九篇。第 5 篇提出了 Event Sourcing 框架——Agent 的执行是一条事件流,不同 UI 是同一事件流的不同投影。这一篇把这个框架展开到六个具体的端,外加 API 这个没有界面的入口,讲清楚每个端各自适合承载什么、不适合什么,以及它们怎么共存。


一、不是多端适配,是多端投影

传统软件做多端,思路是“一套代码,多处适配“——同一套业务逻辑,为每个平台写不同的 UI 层。

Agent 多端的思路不完全一样。不是“把同一个聊天界面适配到 6 个屏幕尺寸“,而是同一条事件流,每个端挑选自己最适合投影的那部分事件,用自己最擅长的交互方式呈现。 CLI 不需要渲染 diff 面板,Mobile 不需要展示 3000 行日志,IDE 不需要做独立权限弹窗——权限确认以内联形式嵌在编辑器里——每个端只消费它擅长消费的事件子集。


二、六端 + API 的定位

CLI:执行入口 + 自动化接口

CLI 是最薄的投影。它的核心能力不是“展示“,而是“执行“——继承 cwd/PATH/env/git/SSH 全部上下文,直接把 Agent 接入工程现场。

适合承载:全量事件流,但以最原始的方式(文本输出)。适合 CI/CD 集成(./agent fix --auto)、管道组合(cat error.log | agent diagnose)、远程执行(SSH + tmux)。

不适合承载:diff 审查、权限管理、多任务队列。

CLI 永远不会被“替代“——因为管道的价值是结构性的,不依赖任何 UI 框架。

TUI:本地工作台

TUI 是 CLI 往上走一层——在终端里提供结构化面板,但保留键盘优先和远程可用的特性。第 2–4 篇拆解的渲染引擎、Markdown 降级和 Agent 事件展平,构成了 TUI 的完整技术栈。

适合承载:执行时间线、工具调用状态、流式输出、权限确认、文件变更列表。适合长时间运行的 Agent 会话(tmux 断线恢复)、低配机器和远程开发场景。

不适合承载:富 diff 审查(并排对比)、多模态附件预览、团队协作。

IDE:代码上下文 + Diff 审查

IDE 插件是天生的 diff 审查工具。编辑器的并排 diff view、行级采纳/拒绝、与语言服务器的集成——这些都是 IDE 的内置能力,Agent 插件只需要对接事件流。

适合承载:file.changed 和 validation.finished 事件的投影——也就是 diff 审查和测试结果。第 8 篇讲的“按文件组织 + 按风险标记 + 部分采纳/拒绝 + 测试关联“,在 IDE 里是出厂能力。

不适合承载:长会话管理、权限审批、多 Agent 任务编排。IDE 擅长“审查“,不擅长“管理“。

Desktop:本地工作台 + 系统集成

Desktop App 的能力介于 TUI 和 Web 之间——有完整的 GUI 控件和原生性能,但不需要浏览器的内存开销。适合独立的 Agent 桌面客户端。

适合承载:系统通知(本地 daemon 在后台跑 Agent,桌面端推送结果)、文件系统集成(拖拽文件到 Agent)、离线恢复(本地 event log 持久化)。

不适合承载:多用户协作(Web 更合适)、纯键盘工作流(TUI 更强)。

Web:团队协作 + 历史 + 审批

Web 是唯一天然支持多用户的端。Agent 产出的 event log 在服务端持久化后,Web 端可以做会话分享、历史搜索、团队审批——这些是 CLI/TUI/IDE 都不擅长的。

适合承载:多任务队列、团队审批流、会话回放和分析、成本统计。Zacp 项目是这条路线的一个实际案例——WebUI + ACP 协议 + 多 CLI Agent:CLI Agent 不知道 WebUI 的存在,WebUI 只消费 ACP 事件,加一个 Web 端不需要改任何 agent 核心。它还顺便回应了一个争论——第 5 篇引过的两种极端观点,“GUI 必然到来“和“GUI Agent 方向根本上错了”(CLI-Anything 的立场),Zacp 恰好是两者的折中实例:agent 核心保持 CLI 原生、走结构化协议,用户界面侧用 Web 做投影。

不适合承载:实时执行(延迟和网络依赖)、本地文件系统操作。

Mobile:Companion,不是主战场

Mobile 不适合做 Agent 的主执行入口——屏幕小、输入慢、后台限制、散热墙、文件系统权限受限。但 Mobile 有一个不可替代的价值:用户离开电脑后,Mobile 是唯一能触达用户的端。 它作为审批触达点的具体场景,第 10 篇展开。

适合承载:任务状态查看、高风险操作审批、执行总结阅读、简单回复。类似“远程任务控制器“,不是“缩小版 IDE“。

API:自动化与集成入口

六个端之外还有第七个入口:API。它没有任何界面——CI/CD 流水线里触发 Agent(./agent fix --auto 背后就是一个 API 调用)、GitHub webhook 回调(PR 打开时自动发起 review)、脚本化工作流(定时任务、批量仓库维护),事件流以 JSON 形式被程序直接消费。

适合承载:自动化触发、机器消费的执行结果、与现有 DevOps 工具链的集成。

不适合承载:任何需要人在环路的交互——API 端的权限必须全部预批,否则任务挂起。它是统一 Permission Engine 的极端投影:所有交互流事件的答案在调用参数里预先声明。


三、不只“适合做什么“:从渲染架构推导端定位

上面六个端的定位看起来像功能分配——“因为 IDE 有 diff view 所以适合审查,因为 Web 有多用户所以适合协作”。但往下挖一层,每个端的渲染架构决定了它天然擅长和不擅长处理什么类型的事件。

第 7 篇详细拆解了各技术栈在 Markdown 流式渲染上的差异。同一个逻辑可以迁移到端定位:

渲染架构核心特征擅长的事件消费模式对应端
纯文本 stdout(ANSI)零布局开销,线性追加,无 undo高频流式事件(tool.started/finished),单维信息CLI
字符网格增量 diff(ANSI)增量 diff + commit/live 分区,可原地重绘高频流式事件 + 结构化面板(工具状态、权限确认)TUI
CoreText 全量 invalidate每次 attributedText 变更触发完整重排,50-100× 富文本开销一次性富文本展示,不适合高频更新原生 Desktop / Mobile
WebView 增量 reflow脏区传播,只重排受影响子树,出厂能力高频局部更新 + 复杂富文本(diff 面板、协作视图)Web
Yoga / Lynx 后台增量后台线程 diff,主线程只应用变更中频更新 + 低延迟交互 + 列表虚拟化Mobile Companion

这里有一个反直觉的结论:原生端(CoreText)做流式 Agent 聊天反而是最脆弱的。 同样的 3000 字流式 Markdown,iPhone 上 CoreText 全量 layout 每次 20-60ms,WebView 增量 reflow 的代价远低于此。所以“Web 适合协作“不只是因为它有多用户能力,还因为它的渲染引擎天生能消化高频事件更新。而 Mobile 原生端之所以只适合做 Companion(通知 + 轻量审批),不是因为屏幕小——是因为在被动散热的手机上,CoreText 全量 invalidate 的高频布局在实测中很容易顶到 thermal throttling,形成恶性循环。

这不是说原生端不该做 Agent GUI。而是说:如果你用原生 CoreText 做流式 Agent 客户端,你的性能天花板比 WebView 低。 选择技术栈的时候,这比“哪个平台用户多“更根本。


四、每个端的交互契约不同

第 5 篇区分了事件的三层分类:执行流、副作用流、交互流。不同端对这三层的消费策略完全不同:

CLITUIIDEDesktopWebMobile
执行流全量文本输出面板化展示跳过(IDE 不关心工具调用过程)通知摘要可回放时间线仅摘要(推送)
副作用流路径字符串路径字符串并排 diff 面板文件列表富 diff + 评论不展示
交互流阻塞 [y/n]输入行阻塞内联弹窗系统通知页面内卡片推送通知
离场处理不支持tmux 挂起不支持本地 daemon服务端超时推送 + 后台

Mobile 的“仅摘要“来自推送通知和任务卡片——对应前面说的“任务状态查看“;副作用流(diff)则完全不投影到手机。没有一个端需要消费全部事件。CLI 和 Mobile 在消费的事件类型上几乎没有交集——但它们消费的是同一条事件流。

补记(2026.09):这张表写成后,“离场处理“一行开始在头部产品里过时——而且是以印证本文的方式。Claude Code 现在有后台会话(/bg)和独立的 agent 视图,配合 Remote Control 和“Push when Claude decides“配置,Agent 可以在用户离开后继续跑、必要时直接往手机推通知——第 10 篇说的“用户离开电脑后,Mobile 是唯一能触达用户的端”,已经是出货的功能,不是设想。“CLI/TUI 离场 = 挂起“从此只是默认行为,不再是能力上限。


五、统一 Runtime,统一 Event Log,统一 Permission Engine,统一 Workspace Model

多端架构的核心约束只有四个:

统一 Runtime。 Agent 的执行逻辑(model loop、工具调用、plan-revise 循环)只有一个实例。不是每个端跑一个 Agent——是一个 Agent 跑在 Runtime 里,所有端只做投影。

统一 Event Log。 append-only,按时间排序,不可变。每个端从这个 log 里读取自己需要的事件。这是恢复、审计和协作的基础设施。

统一 Permission Engine。 权限请求从 Agent Runtime 发出,所有端都可以响应。谁先响应就用谁的结果。超时策略和信任规则持久化在 Permission Engine 层,不是某个 UI 层。

统一 Workspace Model。 cwd、git branch、文件系统状态——这些不属于任何 UI,属于 Workspace。Agent 操作的是 Workspace,不是某个端的“视图“。

满足这四个统一之后,每个端只是一个轻量的投影层——消费事件、渲染 UI、发送用户输入回 Runtime。你加一个 Mobile 端不需要改 Agent 逻辑,加一个 Web 端不需要改权限模型。


六、不是所有产品都需要所有端

六端 + API 是分析框架,不是 checklist。一个独立开发者的 Agent 工具可能只需要 CLI + TUI。一个团队协作的 Agent 平台可能需要 Web + IDE + Mobile。选哪些端,取决于用户在哪里、用户在那个场景下需要消费哪些事件。

关键不是端多不多,而是每加一个端,是不是只需要写投影层——而不是重写 Agent 核心。如果每次加端都要重构事件模型和权限引擎,说明架构还没到位。


七、下一篇

最后一篇。从具体的技术和架构回到人——一个大前端开发者,在 Agent 时代的能力地图是什么?移动端、桌面端、TUI、全栈——这些经验怎么迁移到 Agent Workflow Engineer 这个新方向上。


本文的多端架构框架参考了 ForgeLoopTUI 的 CoreRenderEvent 设计、Zacp 的 ACP 多 Agent WebUI 架构,以及第 5 篇提出的 Event Sourcing 模型。

Agent 时代的开发者界面(十):移动端的位置 + 从大前端到 Agent Workflow Engineer

系列第十篇,最后一篇。前九篇从第 2 篇的 ANSI 序列一路讲到多端架构——完整的技术和设计全景。这一篇收束到个人:做移动端、桌面端、TUI、全栈这些经验,在 Agent 时代到底意味着什么。


一、移动端在 Agent 产品中的位置

先把这个说清楚。移动端不适合做 Agent 的主执行入口——这不是“屏幕小“的问题,是结构性的:

  • 长运行:Agent 的一个任务可能跑 20 分钟。iOS 的后台限制让长运行进程几乎不可能。
  • 高密度文本:diff、日志、堆栈——这些在 6 寸屏幕上阅读体验极差。
  • 本地工具链:编译器、包管理器、git——手机上跑不了完整的开发环境。
  • 散热:持续高负载 = thermal throttling = 恶性循环。第 7 篇的 CoreText 例子已经展示了移动端的散热墙有多真实。

但移动端有一个不可替代的位置:human-in-the-loop 的最后一公里。

用户离开电脑。Agent 在后台继续跑(Mac 上跑着本地 daemon,或者远程服务器上跑着 Agent)。碰到一个需要确认的操作——移动端推一条通知。用户看一眼——知道 Agent 在做什么,批准或拒绝。Agent 继续。

这不是“把桌面端缩小“。移动端 Companion 应该极简:当前任务状态、最近一次权限请求、一键批准/拒绝。类似远程汽车钥匙——不需要方向盘,只需要锁门和解锁。

从移动端交互经验里带过来的直觉:在受限屏幕上,你学到的不是“怎么塞更多信息“,而是“什么信息值得放上去“。 这个克制力在做 Agent 的任何一端时都有用。


二、一个全栈开发者的经验怎么迁移

这几项我都做过——移动端、桌面端、TUI、全栈。回头看,每一项在 Agent 时代都有直接的迁移路径:

移动端 → 权限和通知 UX。 移动端的天性就是处理权限——相机、定位、通讯录、通知。授权粒度怎么分(以定位权限为例:授权状态其实只有“使用期间 / 始终“两级,弹窗上那个“允许一次“是临时的“使用期间“,App 停止使用后失效;而且首次弹窗根本没有“始终“选项)、授权疲劳长什么样、推送通知的触达率和骚扰率之间怎么平衡,这些是移动端开发者的日常。我做 FlowDown 的时候,这些经验直接映射到了 Agent 的权限模型和 Mobile Companion 设计上。

桌面端 → 本地集成和系统交互。 文件系统权限、shell 环境继承、code signing、auto update、crash recovery——第 1 篇和第 6 篇反复提到的 GUI 额外成本,做桌面端的时候我已经交过学费了。Agent Desktop App 是这些经验的直接应用场景。

TUI → 高密度文本信息架构。 写 ForgeLoopTUI 的过程,就是在一个只有字符的终端里把信息组织清晰的过程——靠的不是像素能力,是信息优先级。什么该放 committed 区、什么该放 live 区(第 2 篇),通知超过 3 条就该移除(第 4 篇),这些判断不是框架给的,是在字符网格里逼出来的。这套判断力在 GUI 上同样适用——信息多了,Panel 不会帮你自动排优先级。

全栈 → Runtime 和持久化设计。 Event log 的 append-only 模型、权限引擎的信任规则持久化、Workspace 状态管理——这些不是 UI 问题,是后端问题。全栈经验让我能同时理解 Agent 的 Runtime 层和投影层,而不是只能做其中一半。

大前端 → 复杂交互状态管理。 大前端的核心能力不是“会写多个平台“,而是管理复杂交互状态——一个操作触发多个副作用、多个异步事件并发到达、用户中途介入改变状态流向。这和第 4 篇拆解的 Agent 事件处理模型是完全同构的能力。我在 ForgeLoopTUI 里手写的 pendingTools 稳定槽位队列 + shiftIndices 偏移追踪,回头看就是一个手动实现的响应式状态管理系统。


三、新岗位方向

如果“Agent 时代的开发者界面“成为一个独立的专业领域,这些岗位可能会出现或已经在出现:

Agent Workflow Engineer。 不设计聊天框,设计 Agent 的工作流界面——工具调用时间线、权限审批流、diff 审查面板、多任务编排。需要同时理解 Agent 的执行模型和 GUI 的交互模式。

AI DevTools Product Engineer。 介于产品经理和工程师之间——不需要手写 ANSI 状态机,但需要知道物理行计算为什么让增量 diff 翻倍复杂。在工程约束和用户体验之间做判断。

Local-first Agent Platform Engineer。 Agent 核心跑在用户机器上,event log 本地持久化,隐私和延迟优于云端方案。需要桌面端开发经验 + Runtime 设计能力。

IDE / CLI Integration Engineer。 把 Agent 嵌入开发者的现有工具链——不是另起一个 Agent 专属界面,而是让 Agent 在 VS Code、终端、git 工作流里自然出现。

这些方向现在还处于早期。但回头看第 1 篇列的那个名单——Claude Code、Codex CLI、Gemini CLI、Goose、Aider、Devin CLI、Qwen Code——每个项目背后都需要这样的人(名单的时间锚点、以及到 2026 年秋天各家 GUI 的进展,见第 1 篇的补记:界面在变多,这类岗位的需求只多不少)。他们写的是 Agent 的界面,但做的不是传统意义上的“前端“。


四、回到系列的开头

十篇文章,从 ANSI 转义序列写到多端架构,回答的是同一个问题:

从 LLM token 到用户看到的画面,中间到底经历了什么——以及,这个经历在不同介质里有怎样完全不同的形态。

第 1 篇问:为什么 Agent 从 CLI/TUI 开始?答案不是因为 GUI 不好,是因为当前最核心的问题不是界面表达能力,而是执行过程不确定、权限边界敏感、本地工程环境复杂。CLI/TUI 以最低成本解决了当前最硬的问题。

第 2–4 篇展示了这套“最低成本方案“底下的真实代价。第 5 篇把问题抽象为 Event Sourcing 框架。第 6–7 篇展示了同一套问题进入 GUI 世界后的新形态。第 8 篇是跨端的信任设计——权限与 diff 这两块“信任界面“,才是 Agent 的主界面。第 9 篇回到全局——多端并存才是终态。

如果这个系列在读者心里留下了一件事,我希望是:

Agent 的界面不是聊天框的变体。它是为一种全新的执行体——不确定、长运行、可中断、可审计、会调用工具——设计的工作流界面。这个领域需要的人,不是“会写聊天 UI 的前端“,而是同时理解终端渲染、事件系统、权限模型、diff 审查和本地开发环境的工程师。


五、没有结语

这个系列写到这里,10 篇的结构性工作完成了。但内容本身还有很多可以深入的点——权限模型的信任规则 DSL、diff 审查面板的具体交互稿、多端 event log 的同步协议、Mobile Companion 的推送通道设计。这些是下一个阶段的事。

(补记:本篇写完时是原定的收官。后来在正文之外又续写了两篇——第 11、12 篇,讲渲染的所有权模型——所以你手里的系列到这里还没有完。)

如果这个系列对你正在做的事情有参考价值,ForgeLoopTUI 和 FlowDown 的源码是最好的延伸阅读。


全系列基于 ForgeLoopTUI v1.2.0 和 FlowDown 的实际源码分析。知乎讨论参考:为什么现在大多 Code Agent 的主形态是 CLI/TUI?。

Agent 时代的开发者界面(十一):渲染的所有权模型——谁为已完成内容记账

系列第十一篇——严格说是续篇:第 0–10 篇构成系列正文,第十篇是原定的收官篇;本篇与下一篇是正文写完后的续写。本篇的写作动机是一次自我修正。第 2 篇我以 ForgeLoopTUI 为样本拆解了 TUI 渲染引擎的完整构造,并默认了一个前提:主流的 Agent TUI 都长这样。写完之后我去读了 Codex、Grok Build(xai-org/grok-build,2026 年 7 月开源)的源码和 Claude Code 的发布产物——发现这个前提是错的。但错的方式比“另一条路线“更有意思:这条被我漏掉的分界线真实存在,而 2026 年中的头部实现没有一家把赌注押在单边——分界线在每家内部都成了一个开关。这篇讲这条分界线,以及四家各自站在线上的什么位置。


一、一个我以为不存在的问题

第 2 篇的结论是:Agent TUI 的渲染,核心是在“终端没有 undo“这片地基上,做增量 diff、物理行计算、帧状态记忆、commit/live 分区。这些内容全部基于 ForgeLoopTUI 的实际源码,描述本身没有错误。

问题在于一个隐含假设:我以为 Claude Code 和 Codex 也是这么做的——毕竟用起来是一样的,流式输出原地更新,工具调用状态行被结果替换,底部输入框固定不动。

直到我在 Codex 仓库里看到这个文件:

codex-rs/tui/src/insert_history.rs

名字就说明了一切:把完成的内容插入历史(insert_history_lines(),用 DECSTBM 滚动区把内容注入 viewport 上方)。配套的测试文件叫 vt100_live_commit.rs——live 区内容“提交“进历史。快照测试里还有 zellij_raw_terminal_wrap_above_viewport.snap 这种为冷门终端复用器准备的用例。

Claude Code 建在自己的 Ink fork 上(发布产物中可见 src/ink/ 目录),它的渲染层保留着同一个分裂:shouldRenderStatically() 判定一条消息是否可以“静态化“,静态内容打印进 scrollback 后不再参与重绘——CompanionSprite.tsx 里一句注释把约束说得很白:“floating into Static scrollback can’t be cleared”(飘进 Static scrollback 的东西就擦不掉了)。

再翻 Grok Build,找到一个独立 crate xai-ratatui-inline,核心函数就叫 emit_to_scrollback——“发射进 scrollback”。我以为找到了第三个同路人。但继续追调用方,故事拐了弯:这个函数在生产代码里没有调用者,只出现在 crate 自带的示例和测试里。Grok Build 的默认形态([terminal] alt_screen = auto)是一个进 alt screen 的全屏 pager,应用自管 scrollback——那恰恰是第 2 篇那条路的工业化版本。真正的 inline 形态是一个默认关闭的实验模式(xai-grok-pager-minimal),而且它也不用 emit_to_scrollback:它用 ratatui 自带的 insert_before(DECSTBM 滚动区)提交定型内容,还立了一个编译期守护测试,禁止任何人把 purge 重印机制接进 minimal 模式。

把四家的默认形态摆在一起,得到的不是收敛,是一张 2:2 的记分牌:

产品默认形态(普通终端)内置的另一形态
Claude Codeinline(Ink <Static> 静态化)可选全屏 /tui fullscreen(2026 年中起,alt screen)
Kimi Codeinline 差分(主屏)实验性全屏(KIMI_CODE_TUI_FULL_SCREEN=1,详见第 12 篇;到 9 月底已升格为正式配置项 tuiMode)
Codexalt screen(tui.alternate_screen = auto,无条件生效——官方文档仍称“auto 在 Zellij 里跳过“,与代码脱节数月,见第七节第 7 条的补正)inline viewport(insert_history.rs 那套)
Grok Build全屏 pager(auto;tmux 控制模式和 Zellij 里降级为全高 inline)实验 minimal inline(xai-grok-pager-minimal)

第 2 篇描述的引擎不是一条“少数派的错路“——它是四家全都内置着的一半。真正的新问题是这条分界线本身:一个 block 完成之后,谁继续为它记账? 以及,为什么每家都在同一产品里养着线的两边。


二、分界线:已完成的内容,谁继续为它记账

先把两个学派定义清楚。

全屏自管派(ForgeLoopTUI;Grok Build 和 Codex 的默认形态所属):屏幕上每一行都归引擎管。每一帧合成完整画面,与上一帧 diff,精确改写变化的行。已完成的历史内容虽然逻辑上“只追加“,但只要它还显示在屏幕上(或活在自己接管的 alt screen 里),就持续参与每帧的 diff 计算和 resize 重算——引擎为它持续记账。

inline viewport 派(Claude Code 与 Kimi Code 的默认、Codex 与 Grok Build 的内置降级路径所属):内容一旦“定型“就立刻打印进终端的原生 scrollback,从此归终端管,引擎销账。引擎自己只维护底部一小块“活区“(viewport),用双缓冲 diff 重绘。

全屏自管派:                     inline viewport 派:
┌─ scrollback ────────────┐    ┌─ scrollback ────────────┐
│ (滚出屏幕才归终端)      │    │ 历史(定型即归终端)      │
├─ 屏幕 ──────────────────┤    │ 历史(定型即归终端)      │
│ 历史      ← 引擎管       │    ├─ 屏幕 ──────────────────┤
│ 历史      ← 引擎管       │    │ 历史(可见但不可触碰)    │
│ live 区   ← 引擎管       │    │ viewport ← 引擎管(仅此处)│
│ 输入框    ← 引擎管       │    │ 输入框(在 viewport 内)  │
└──────────────────────────┘    └──────────────────────────┘

这里要特别强调:两派的 live 区是同构的。 ForgeLoopTUI 的增量 diff、Codex 的 ratatui 双缓冲、Ink 的行级 erase-rewrite,做的是同一件事——这正是我当初误判阵营的原因。表层行为一模一样。

真正的分歧只有一个问题:一个 block 完成之后,谁还继续为它记账?

  • ForgeLoopTUI:完成了也继续记。committedLines、lastCommittedPhysicalRows 等帧状态字段持续追踪它,直到它滚出屏幕顶边。
  • inline 派:完成即销账。之后它的滚动、搜索、选择、复制都是终端模拟器的事,引擎一行代码都不用写。

三、inline 派的机制:发射即定型

这套机制最干净的教科书版本在 Grok 的 crate 里——emit_to_scrollback 全部逻辑只有 60 行(xai-ratatui-inline/src/scrollback.rs,以下为简化伪代码):

#![allow(unused)]
fn main() {
fn emit_to_scrollback(terminal, content) {
    // ① 光标移到 viewport 顶部,从这里往下全部擦掉
    //    —— viewport 里现在画着输入框,发射前先把自己的活区擦干净
    move_to(0, viewport.y);
    print("\x1b[J");

    // ② 把内容打印在擦出来的区域。长行不做换行计算——
    //    wrapping 是终端的工作
    print(content_segments);

    // ③ 打印 viewport.height 个空行,在内容下方"顶"出一块空白
    for _ in 0..viewport.height { print("\r\n") }

    // ④ 光标移到新的 viewport 位置,再擦一次
    move_to(0, new_viewport_y);
    print("\x1b[J");

    // ⑤ 双缓冲作废,viewport 全量重画;记账只有一个 Rect
    terminal.reset_back_buffer();
    terminal.set_viewport_area(new_viewport_y);
}
}

不变式值得逐字读:viewport 是可擦除的墨水,scrollback 是泼出去的水。 每发射一批内容,就先擦掉自己画的输入框、把内容印在那个位置、再在下方顶出新的空白区。从终端的视角看,这个程序从头到尾只做两件事:往下打印、在底部反复涂改一小块。

但要如实说一句:这个参考实现在 Grok 产品里目前没有生产调用方。真正在跑的生产路径用的是同一思想的更保守版本——Codex 的 insert_history_lines() 和 ratatui 自带的 insert_before 都走 DECSTBM 滚动区,把内容“插“到 viewport 上方而不是擦掉重印;Grok 的 minimal 模式直接复用后者。思想是同一条,工程上各家选了介质兼容性更好的变体。

这套机制的纪律性质,在 Grok 仓库里有一个字面物证。xai-grok-pager-minimal/src/guard.rs 是一个守护测试:它用 include_str! 扫描 minimal 模式的每一个源码文件,断言其中不出现 emit_to_scrollback、resize_purge_rerender、resize_viewport_height 这三个标识符——注释把理由写得很白:这些函数会重印已经归终端的历史,结果是 “double-printing (or, with ED3, wiping) committed scrollback”。这条路线赖以生存的纪律,在这里不是口头约定,是一个挂了就算构建失败的测试。

对照第 2 篇的记账成本。全屏自管派维护 8 个帧状态字段,错一个全屏偏位;inline 派的全部渲染状态是一个 Rect(实质只有 y 在变)加一块可以随时作废的双缓冲。它不追求“记住屏幕“,它追求“随时能重建“。

发射时机的选择也随之改变。第 2 篇的 Live Budget Planner 是空间驱动——live 区超预算就把旧行沉降到 committed。inline 派是语义驱动——一个 block 在事件流里完成了(工具返回了、回复结束了)才发射。“什么时候算定型“从几何问题变成了生命周期问题。 Claude Code 的 shouldRenderStatically()(src/components/Messages.tsx)把这个判定写得很直白:一条消息想“静态化”,需要它关联的 tool use 不在 streaming、不在 in-progress、已全部 resolved,连同组的兄弟 tool use 和 PostToolUse hooks 都结束了才行。推论是:未定型的内容必须钉在 viewport 里。一个跑五分钟的工具调用,它的 block 就在 viewport 里被反复重绘五分钟,直到拿到结果、定型、发射。

这个变化和第 5 篇的 Event Sourcing 框架是自洽的:事件完成 → 投影定稿 → 归档。inline 派相当于把 Event Sourcing 在渲染层做到了底——投影一旦定稿,连撤回投影的能力都主动放弃。


四、第 2 篇的痛点在这条路线下的命运

第 2 篇的问题全屏自管派的解法inline 派的答案
物理行/逻辑行错位每行 visibleWidth + physicalRows,每帧依赖只在发射瞬间做一次切分;发射后永不再算
8 个帧状态字段持续维护,错一处全盘偏一个 Rect + 可作废的双缓冲
Live Budget Planner超预算沉降,不做部分行切割机制消失,由 block 生命周期替代
用户两帧之间滚动假设破坏,diff 坐标出错历史区滚动是终端的事,不构成假设
历史的搜索/选择/复制需要自己实现(或放弃)终端原生免费——Cmd+F 直接可用
resize缓存全失效,重算重绘当前屏见下节,这是 inline 派交税的地方

前五行是真实的收益。但税没有消失,只是换了征收点。


五、税单:inline 派的代价

5.1 发射后的内容不可触碰

ANSI 光标序列只能寻址可见屏幕。一行内容滚过顶边进入 scrollback 后,不存在任何转义序列能再碰到它。而对还停在屏幕上、位于 viewport 上方的历史,inline 派给自己立了纪律:光标从不越过 viewport 顶边——一旦回写,就重新背上了帧状态记账的债,这条路线的全部意义就消失了。

产品后果:用户想展开一个三分钟前完成的折叠 block,历史不能改。Grok Build 的 minimal 模式的答案是 full_view.rs——把整个 transcript 以强制展开模式重新渲染成一段 ANSI 文本,交给 $PAGER(less -R)。“重新消费历史“的需求被路由到外部 pager,而不是历史改写。 不可变历史 + 按需导出,是这条路线对“可改写性“需求的最终回答。(应用内的模态查看器 Grok 也有——block_viewer.rs,Ctrl-F 打开,提供搜索、选择、复制——但它长在默认的全屏 pager 里。那边的历史本来就归应用管,模态查看器是功能,不是妥协。)

5.2 resize 的核平重印

终端 resize 时,终端会先自动 reflow scrollback,应用随后才收到 SIGWINCH——边框被拆花,且各家终端的 reflow 行为不一致(grok 的注释原话:试过按字符数自己算 reflow,被各家终端的边界行为打回来了)。inline crate 里留着的解法是不修,直接重建(xai-ratatui-inline/src/resize.rs):

#![allow(unused)]
fn main() {
// 注释是个事故报告:本来可以用 RIS (\x1bc) 硬重置,
// 但 RIS 在 iTerm/Terminal.app 里不清 scrollback
write("\x1b[2J\x1b[3J\x1b[H");   // 清屏 + 清 scrollback + 归位
print(history);                  // 内存里的全部历史按新宽度重新打印
// 顶出 viewport 空行、重算位置、clear
}

(同一个文件的文档注释和 README 还写着“用 RIS 重建“的旧方案——文档滞后于代码,以代码里这段事故报告式的注释为准。)

走这条路的前提是必须在内存里保留完整的 block 数据模型——不是为了逐帧渲染,是为了 resize 后能重印,以及支撑应用内的搜索/选择/导出。值得记下的是,Grok 的 minimal 模式拒绝走这条路:它宁可使用终端内建的 autoresize、忍受各家 reflow 的差异,也绝不调用 purge 重印——因为 \x1b[3J(ED3)会把已经归终端的 scrollback 一起清掉。guard.rs 那个守护测试,禁的正是这条路。

代价有两笔。一是 I/O 爆发:pty 是串行字节流,3 小时会话的历史是几万行、几 MB 文本,每次 resize 都重推一遍(用同步输出模式压住闪烁)。二是 ESC[3J 误伤无辜:它清空终端的整个 scrollback,包括启动 agent 之前 shell 里留下的内容。

对照第 2 篇:全屏自管派的 resize 成本是 CPU(物理行重算),量级是一屏;inline 派是 I/O 带宽,量级是整场会话。账单寄到了不同的地址。

5.3 终端免费给的能力,想用就得自建一份

终端 scrollback 的滚动/搜索/选择对人免费,但对程序是黑箱:不能导出会话、不能程序化跳转、不能带格式复制。想把历史当数据用,只能靠内存里那份模型自建。Grok Build 那份 4242 行的 text_selection.rs(连同独立的 scrollback pane、history search、export 模块)就是这类自建的样子——不过严格按所有权划分,它长在默认的全屏 pager 里,是全屏派赎回原生能力的赎单(第 12 篇会逐条点这张赎单);inline 派的 minimal 模式反而没有补这张票,它把“再看一眼历史“导出给了 $PAGER。

5.4 活区膨胀的退化

长任务场景下(后台任务跑 20 分钟、权限等待用户离开),未定型 block 一直钉在 viewport 里参与每帧 diff。“活区极小“的优势被稀释,渲染负担向全屏自管派回归。


六、为什么分界线变成了开关

我初稿把四家写成了“收敛到 inline“,那是错的。真实的图景是 2:2 的默认分岔 + 全员双模。逐项分析之后,我认为有几个结构性原因。

1. 对话的介质本性。 Agent 会话天然 append-only。为“历史原地改写“这个低频需求维护一条常驻的精确 diff 管道,是三条路线里最不划算的一笔投资。模态 viewer 和 $PAGER 导出覆盖了真实需求的大部分。这是 inline 派的拉力。

2. 失败模式的不对称。 全屏自管派的失败形态是屏幕上出现无法解释的乱码——IME 劫持光标、tmux 不同步、resize 时机错误——难复现、难定位,且发生在用户最看重的地方(别忘了这是一个需要用户审计 agent 行为的产品)。inline 派的失败形态是 resize 时闪一下——可见、有界、可恢复。对商业产品,这个不对称是决定性的。

3. 纪律比天才便宜。 8 个帧状态字段时刻正确,是一个只有引擎作者本人才能长期维护的承诺;“发射后永不触碰“是任何新人都能遵守的纪律。大组织选架构,选的是“不容易被改坏”。Grok 把这个论点推向了字面化:minimal 模式的纪律不是 Code Review 把关,是 guard.rs 里一个扫描全部源码的守护测试——纪律被写进了构建。

4. 成本支付频率的不对称。 全屏自管派的维护成本每天都在付(任何渲染改动都要过帧状态);inline 派的爆发成本只在“长会话 + 用户拖窗口“的交集发生时付。pty 本身的吞吐是几十 MB/s,瓶颈其实在终端模拟器的解析渲染,但即便保守估计,几 MB 重印也只是百毫秒级的事。为罕见事件付重税,为日常事件零税。

5. 可测试性。 emit_to_scrollback 的测试是 MockTerminal 接住字节流、断言 clear 了几次、flush 了几次——简单到近乎无聊,因为逻辑本身就简单。系统越聪明,证明它正确的基础设施越贵。

以上五条解释了 inline 派的拉力,但解释不了为什么 Grok 和 Codex 把默认押在了全屏。把开关另一端的原因补上:

6. 会话内体验是真需求。 滚动、搜索、选择复制、平价 resize(全屏派 resize 是 O(屏高) 的 CPU 操作,没有 I/O 爆发)、鼠标交互——长会话用户每天都会用到。终端原生 scrollback 免费提供这些,但只对“人“免费,不对“程序“免费;而全屏派自建一份之后,这些能力对人和程序同时成立。

7. 用户环境是分裂的——但对分裂的回应正在换代。 同一家公司必须同时服务 iTerm2 用户和 tmux/Zellij 用户,复用器(尤其 Zellij 和 tmux 控制模式)的 scrollback 语义确实让 alt screen 变成事故现场。Grok 的答案保留至今:auto 在 tmux 控制模式和 Zellij 里降级 inline。Codex 的答案则经历了一次转向,还留下一个文档与代码脱节的活案例:它曾经有同款特判——tui.alternate_screen = auto 在 Zellij 里跳过 alt screen,官方配置文档至今仍写着这句话;但代码在 2026 年 5 月中就把特判整个删掉了(#22214,提交标题就叫 “remove Zellij TUI workarounds”),auto 从此无条件进 alt screen,还配了一个测试(alternate_screen_auto_uses_alt_screen)把新行为钉死。我初稿按文档口径写了“auto 在 Zellij 里跳过“,发布前对源码才核出这个脱节,此处补正。特判也并非被简单扔掉,而是换了一层活法:新的 ScrollbackStrategy 在运行时检出 Standard/Zellij/FullScreen 三档终端,决定历史插入和视口生长各自用什么手法(9 月又为 Windows Terminal 补了一档——部分 DEC 滚动区在那儿会丢行)。按终端切模式的开关退役了,按终端选策略的适配接了班。用户环境的分裂没有变,变的是行业对它的回答:从“检测终端、切换模式“走向“统一模式、下放策略“——顺带证明,连“开关是必然产物“这种判断,保质期也可能只有几个月。

8. 历史有两种消费场景。 会话中(要交互、要导航)和退出后(要在终端里向上翻、Cmd+F、复制)对所有权的要求恰好相反。两个场景都真实、都高频——于是四家长出了同一答案:都养一套,或者串行地各管一段。这正是第 12 篇的主题。


七、这对本系列意味着什么

先说修正范围,比预想的小:

  • 第 2 篇的技术内容不需要改——它描述 ForgeLoopTUI,而 ForgeLoopTUI 确实那样工作。需要补的是一个坐标系:TUI 渲染引擎存在两个学派,第 2 篇解剖的是全屏自管派的手工形态(Grok Build 的默认 pager 是它的工业化形态),而 Claude Code 与 Kimi Code 的默认属于 inline viewport 派。两派的分界不在“live 区怎么画“(那层同构),在“已完成的内容,谁继续为它记账“。
  • 第 4 篇只需一句限定:槽位队列和 shiftIndices 作用于 live 区;inline 派把乱序配对提前到了数据层——block 按稳定 id 在内存模型里配对完成后才发射,乱序问题从坐标手术退化为字典查找。
  • 第 3、5、6–10 篇不受影响。第 5 篇的 Event Sourcing 框架反而被 Grok Build 的架构(TUI/headless/ACP 三端共用同一事件流)实证加强。

然后说我的引擎的位置。ForgeLoopTUI 是少数派,但不是错误——Grok Build 和 Codex 的默认形态证明了全屏自管在大组织手里同样能产品化。这条路能做到 inline 派结构性做不到的事:任意可见历史的原地改写、每一帧每一个 ANSI 字节的精确控制、会话内完整的交互能力。如果未来出现“历史必须活“的产品需求——实时协作标注、历史中的 diff 就地更新——inline 派三个实现都得重构,全屏自管派不用。

但演进方向也因此清晰了:ForgeLoopTUI 的 committed 区在语义上已经是 append-only,和 inline 派的 scrollback 只差最后一步——物理上还归不归我管。四家的开关给出了同一个提示:这道题不必一次答完,可以按生命周期分期回答。这笔交换是否值得,取决于约束集合——这正是第 9 篇“端的选择由约束决定“的同一个逻辑,在渲染层又成立了一次。


八、收束

第 2 篇说:TUI 渲染是在“终端没有 undo“的地基上建引擎。这篇补上下半句:地基没法换,但你可以选择和地基的关系。

全屏自管派征服介质:终端没有 undo,就造一个 undo(光标手术 + 帧状态),换来 GUI 级的控制力。inline viewport 派顺应介质:终端的本性就是电传打字机,只进不退——那就不退,把“不可变“从限制变成架构原则。

两种选择没有高下,是同一条公理的两种缴税方式:一个持续为“可改写性“纳税,一个一次性买断“不可改写“、再为重印机制和应用内补偿补票。2026 年中的头部产品用配置开关承认了这一点:四家全都内置了两种所有权模型,区别只在默认押哪边、以及另一边做到什么完成度。

我漏掉这条分界线的原因值得记下来:两条路线在视觉上无法区分。我是读了源码才发现的。这也是本系列反复出现的主题的另一个实例——看起来一样的界面,底下的成本结构可以完全不同。 区别不在像素里,在所有权里。

下一篇(第 12 篇)把这个问题推进到工程细节:既然双模已成事实,那“同一引擎里两种所有权共存“到底怎么实现?Kimi Code 在四家里给出了接口最干净的一个样本——包括一个前无古人的设计:退出时把历史归还给终端。


本文基于 Grok Build(commit 9fabade,2026-08-16;xai-ratatui-inline、xai-grok-pager、xai-grok-pager-minimal)、Codex(codex-rs/tui/src/insert_history.rs、tests/suite/vt100_live_commit.rs;tui.alternate_screen 的 Zellij 特判已于 2026-05-13 在 #22214 中移除,官方配置文档至今仍写旧行为——本文初稿按文档口径转述,2026-09-29 依本地源码(main 6b9826e3,2026-09-09)补正,见第七节第 7 条)与 Claude Code 发布产物中的源码(src/components/Messages.tsx 的 shouldRenderStatically、src/utils/staticRender.tsx、src/ink/ 内置 fork)的实际阅读。对照部分基于 ForgeLoopTUI v1.2.0。

Agent 时代的开发者界面(十二):双模渲染——同一引擎里的两种所有权

系列第十二篇。第 11 篇讲了 TUI 渲染的两条路线——全屏自管派与 inline viewport 派——以及一个新事实:头部 Agent TUI 没有一家押注单边,分界线在每家内部都成了开关(默认形态 2:2)。月之暗面 2026 年把 Kimi Code 的源码放了出来(MoonshotAI/kimi-code,MIT,TypeScript monorepo),它是这条线上最适合解剖的样本:它的渲染引擎同时实现了两种所有权模型,用同一个接口、同一棵组件树,运行时二选一,而且代码里处处留着两派交锋过的事故记录。这篇是这个案例的解剖。


一、基本面:一个 vendored fork,两个渲染器

Kimi Code 的 TUI 框架在 packages/pi-tui,是从上游 pi-mono 项目的 pi-tui vendored 进来的 fork。这个 fork 的自我管理方式值得先说一句:pi-tui/AGENTS.md 逐条列出“我们和上游的 8 处分歧“,每条都带守护测试,并明确规定——每次从上游同步后,这 8 条必须逐条重新验证,测试挂了就意味着本地分歧被覆盖丢失。这是把“fork 的维护成本“显性化的做法,和第 11 篇说的“纪律比天才便宜“是同一个精神。

渲染层的关键结构是:TuiBase 提供共享的组件树、overlay 合成、光标提取;TUI 是个接口,有两个实现——

apps/kimi-code/src/tui/tui-state.ts:

  fullscreen = KIMI_CODE_TUI_FULL_SCREEN === '1'
    ? new TuiAltScreen(terminal, ...)   // 实验性全屏
    : new TuiMainScreen(terminal)       // 默认

(写法以文末标注的版本为准:8 月中它还是环境变量开关,到 9 月底,main 分支已经把这个开关升格为正式的 tuiMode 配置项——“实验开关“转正成“产品配置”,方向正是本篇和第 11 篇判断的那条路。)

  • TuiMainScreen(629 行):渲染进终端主屏 + scrollback。inline 派,但是差分变种。
  • TuiAltScreen(1302 行):alt screen 全屏,应用自管视口。全屏自管派,工业级形态。

也就是说,第 11 篇那条我以为只存在于不同公司之间的分界线,在这个仓库里是同一个 if 的两个分支。这给了我一个前所未有的对照机会:同一棵组件树、同一份组件代码,喂给两种所有权模型,各自的账单会开在哪里。


二、主屏渲染器:inline 派的差分变种

2.1 模型:全量渲染,增量落地

TuiMainScreen.doRender() 的模型一句话:每帧把整棵组件树(全部历史消息 + 编辑器)渲染成一个扁平行数组 newLines,和上一帧 previousLines 逐行比较,只把变化补丁写入终端。

等等——每帧重渲整棵树?这和 Grok Build 的“发射即销账“不一样。关键在它对历史的处理方式:历史行渲染一次之后,靠引用相等缓存摊薄成本。组件的渲染缓存对未变化内容返回同一个字符串引用,于是稳态帧的成本是 O(总行数) 次指针比较,只为真正变化的行付处理费(tui-main-screen.ts:237-261,注释原文:“a steady frame only pays for the lines that actually changed”)。

所以准确地说,它不是 Grok 那种“定型后彻底不管“,而是:逻辑上整棵树都活着,物理上只补丁视口可达的部分。

2.2 所有权分界线被压缩成一个整数

渲染器维护一个 previousViewportTop:逻辑行数组里,这个下标以上的行已经滚进 scrollback,归终端;以下的行在屏幕上,光标可达,归引擎。

第 11 篇用了一整节讲“已完成的内容归谁记账“,在这里它就是一个整数加一句检查。整个 doRender() 的题眼是这几行(tui-main-screen.ts:449-455):

// Differential rendering can only touch what was actually visible.
// If the first changed line is above the previous viewport, we need a full redraw.
if (firstChanged < prevViewportTop) {
    fullRender(true);
    return;
}

变化落在视口之内 → 局部补丁;变化越界进 scrollback → fullRender(true):\x1b[2J\x1b[H\x1b[3J 清屏、清 scrollback、全量重印。这就是第 11 篇“历史可改写:不可能,除非整体重印“的判官代码。

2.3 决策树:一串快速失败路径

doRender() 的主体是一串按优先级排列的兜底分支(319-455 行),每条都对应一类真实事故:

  1. 首帧:fullRender(false),不清屏直接写——假设屏幕是干净的;
  2. 宽度变化:核平重印。换行全变,没有局部解——和 Grok Build 的 resize_purge_rerender 逐字相同;
  3. 高度变化:原则上也核平——但 Termux 除外。注释写着:Android 软键盘弹出/收起会改高度,如果每次核平,“the entire history to replay on every toggle”(整个历史在每次键盘切换时重播)。一条特例就是一份事故报告;
  4. 内容收缩到历史峰值以下:核平(否则残留的空行清不掉);
  5. 以上都不命中,才进入差分主路:扫描出 firstChanged/lastChanged,只重绘变化区间而非“变化行到末尾“——注释明说动机:“reduces flicker when only a single line changes (e.g., spinner animation)”;
  6. 纯追加(流式输出的常态)走 append 快速路径:光标滚到底直接写。

外加两个通用工程点:所有写包在 \x1b[?2026h/l 同步输出里(原子帧,防撕裂);kitty 图片协议有一整套影子账本(图片行占位行数、changed-range 膨胀、按 id 删除),因为图片在终端里是字节流之外的第二套状态。

2.4 和 Grok 的哲学分野:预防 vs 检测

把两家并排,inline 派内部其实有两个档位:

  • Grok Build(实验 minimal 模式):数据层预防。 block 不到 committed frontier 不发射,发射即归终端——从架构上保证“变化永远不可能越界“。(内存里仍保留完整数据模型,供 full_view 导出和搜索用,只是不再参与逐帧渲染;见第 11 篇的税单一节。)
  • Kimi Code 主屏:渲染层检测 + 核平兜底。 整棵树每帧都可变,渲染器不假设应用守纪律;它在每帧末尾检查变化是否越界,越界就付核平的代价。

前者把正确性建立在纪律上,后者把正确性建立在一句话的运行时检查上。代价结构不同:Grok 的 resize 和“历史变了“都走重印;Kimi 主屏的 resize 走重印,“历史变了“也是重印——但因为整树活着,它不需要额外的纪律来维持不变式,应用层可以随便改数据,渲染层总会收敛到正确画面。这是用偶尔的重印换架构上的免维护。

2.5 变更史里的税单实录

第 11 篇推导的 inline 派三大代价,在 Kimi Code 的 CHANGELOG 里每条都有对应事故(#1353、#1367 见 packages/pi-tui/CHANGELOG.md,#2442、#1188 见 apps/kimi-code/CHANGELOG.md):

  • scrollback 不可改:#1353——流式输出收缩/膨胀循环时,视口锚定出错,把内容重复堆进 scrollback(重复发射);
  • 自我修正的成本:#1367——这个 fork 曾经自己改过 viewport/scrollback 的渲染行为,后来整体回退,回归上游的差分渲染(AGENTS.md 里写着“the fork’s viewport/scrollback rendering patches were reverted“)。在 scrollback 边界上自作聪明,最后被介质教育了;
  • I/O 与重绘风暴:#2442、#1188 反复压全屏重绘次数(“Reduce frequent full-screen redraws”)。

读别人的 CHANGELOG 读到这些条目,感觉是看到自己文章里的推导在别人的事故报告里逐条兑现。


三、全屏渲染器:全屏自管派的工业级形态

TuiAltScreen 是我那派的实现——但它的成熟度远超我自己的 ForgeLoopTUI,值得逐块拆。

3.1 模型:文档 + scrollTop + 视口重绘

进 alt screen(\x1b[?1049h),关自动换行,开鼠标。文档是完整行数组,ScrollView 持有 scrollTop;每帧经 renderLayoutFrame 只排出可见的 height 行,然后逐行和上一屏 previousScreen 比较,变化的行用光标寻址覆写(\x1b[row;1H\x1b[2K + 新行)。没有 scrollback 参与,没有任何“发射“。

先看我那派的核心优势在代码里的直接兑现——resize。尺寸变化只是把 fullRedraw 置真,代价是 \x1b[2J + 重画一屏 height 行(tui-alt-screen.ts:1270-1276)。没有 3J,没有历史重印,没有 I/O 爆发。主屏那条路的“核平“在这里是 O(屏高) 的平价操作。第 11 篇说全屏自管派的 resize 成本是 CPU 且量级是一屏——这里是逐字的证据。

3.2 赎回清单:终端免费给的,全部自建

第 11 篇说 inline 派“终端免费给的能力“是红利,反过来说,全屏派要把这些能力一件件赎回来。这份赎单在 tui-alt-screen.ts 里是明码标价的:

  • 滚动:ScrollView 抽象(scrollTop、follow: "end"、overscroll: "contain"),而且是嵌套滚动——滚轮事件按坐标在布局树里命中测试,命中最内层 ScrollView,滚不动的余量溢出给外层(routeWheel)。这是 GUI 的 nested scroll view 语义,在字符网格上重建了一份;
  • 搜索(Ctrl+Shift+F):匹配在数据层做(对完整文档行全文查找),高亮在绘制层做——applySearchHighlights 把命中文段从渲染好的行里切出来、包上样式、再拼回去,数据一个字不动。跳转有 anchor 保持和 next/previous 环绕;
  • 选择复制:alt screen 里终端的原生选择是废的(你在不停重绘),于是实现了应用级选择——SGR 鼠标事件、单击拖选、双击选词、三击选行(getClickCount 按 500ms 窗口计次)、拖到视口边缘 50ms 定时器自动滚屏、反色高亮同样是绘制层拼接(还会小心地保留原行的 ANSI 序列)、松手时 OSC 52 写系统剪贴板。这一段占了文件将近一半的行数;
  • 语义锚点(OSC 133):transcript 的消息带着 OSC 133 区域标记下发,绘制时剥掉,但滚动导航用它实现“上一个/下一个 prompt“跳转——用终端的 shell 集成协议,给应用级滚动加了语义站点。这个设计我觉得是全文件最优雅的一笔;
  • kitty 图片预算缓存:滚出视口的图片不删会泄漏终端显存,于是屏外缓存带预算上限(16 张 / 32MB 传输字节 / 64MB 解码字节),超预算逐出。

还有一处环境妥协值得记:鼠标默认开全量移动追踪,但检测到 tmux/zellij/screen 就降级为“仅按键时追踪“——复用器转发鼠标事件会卡。每个免费能力的赎回价里,都包含一笔兼容性税。

3.3 最妙的一笔:退出时归还 scrollback

afterTerminalStop(305-328 行):全屏模式退出时,把整个文档重新打印回主屏——逐行 \r\x1b[2K + 内容,写完 \r\n 收笔。

效果是:会话期间历史归应用(可滚动、可搜索、可选择、resize 免费),退出之后历史落进终端原生 scrollback,用户照常往上翻、Cmd+F、用终端自己的选择复制。

第 11 篇的两派在这里出现了第三种所有制:

实现会话期间会话结束后
Grok Build / Claude Code / Codex历史归终端归终端
ForgeLoopTUI(我的)历史归引擎留在主屏,但无干净归档(全量重印会把陈旧副本顶进 scrollback);内存模型随进程蒸发
Kimi Code alt 模式历史归引擎归还终端

生前归自己,死后归终端。 运行期享受全屏自管的全部自由,退出时把资产清算回介质本身。两派的优点被串行地各取了一段。


四、这个案例对本系列意味着什么

1. 第 11 篇的分界线被复用了,但没有被推翻。 那条分界是真实的结构——真实到四家的开关都压在同一条线上,而 Kimi Code 是在同一个接口后面把两边都实现得最完整的一个。默认主屏(inline 派)说明:对一个要集成进用户日常终端的 CLI,“历史归终端“仍是默认正确答案;实验性全屏说明:会话内体验(滚动、搜索、选择、resize)的诉求强到值得再养一套引擎。

2. inline 派内部要分两档。 第 11 篇把 inline 派写成一种做法(发射即定型),Kimi 主屏的差分变种给出了第二种:全量渲染 + 增量落盘 + 越界检测 + 核平兜底。两档的不变式相同(scrollback 不可触碰),但维持不变式的方式一个是数据层纪律、一个是渲染层检查。这个区分应该补进第 11 篇的框架里。

3. 我那派的“赎回清单“有了工业参照。 第 2 篇我写的是引擎的渲染核心;Kimi 的 alt 模式展示了渲染核心之外,把一个全屏 TUI 做到产品级还需要什么:嵌套滚动、应用级选择、数据层搜索、OSC 133 锚点、图片显存预算、复用器降级。每一项都是“终端免费给的“对照项。

4. “退出归还 scrollback“是一个新的架构选项。 它回避了两派各自最难堪的时刻:inline 派在会话中无法提供可交互的历史,全屏派在退出后把历史带走。串行混合——运行期自管、终态归还——让“所有权“从一个静态选择变成了一个随生命周期变化的转移过程。如果 ForgeLoopTUI 未来要解决“退出后没有干净归档“的问题(它跑在主屏,退出后最后一帧仍在屏幕上,但全量重印顶进 scrollback 的陈旧副本让那份记录不可用),这是可以直接借鉴的范式。


五、收束

第 11 篇的结论是:两派没有高下,是同一条公理(终端没有 undo)的两种缴税方式。Kimi Code 这个案例把结论往前推了一步:既然只是缴税方式,就可以按生命周期分期缴。

运行期,把历史留在自己手里,缴“自建滚动/搜索/选择“的税,换来可交互的会话;退出时,把历史打印回 scrollback,缴一次“全量重印“的税,换来用户终端里的完整记录。甚至同一个引擎里还可以再备一套 inline 差分实现,缴“核平兜底“的税,作为默认模式的日常形态。

所有权模型因而从一道单选题变成了一份调度表:什么东西、在什么阶段、归谁记账——每个问题都可以单独回答。介质还是那个只进不退的电传打字机,但和地基的关系,比第 11 篇设想的还要再自由一点。


本文基于 Kimi Code 源码的实际阅读:packages/pi-tui/src/tui-main-screen.ts(主屏差分渲染器)、packages/pi-tui/src/tui-alt-screen.ts(全屏渲染器)、apps/kimi-code/src/tui/tui-state.ts(模式选择)、packages/pi-tui/AGENTS.md 与两处 CHANGELOG(fork 分歧与事故史)。行文时 Kimi Code 版本为 commit 1e553fc(2026-08-18)。ForgeLoopTUI 的退出行为核对基于其 v1.2.0 源码:主屏渲染,无 alt screen,无退出时清屏/归还逻辑。

Agent 时代的开发者界面(十三):收敛的另一面——pi-mono 暗线与生态同源

系列第十三篇,第 11、12 篇的直接续篇。第 11 篇发现渲染所有权的分界线在四家头部产品里都成了开关,我把它当作“行业收敛“的证据写了下来。这一篇补另一半:我去取证 MiniMax 的 mcode,本想给对照表加第六个样本,结果在它的发布产物里 grep 出一串不属于 MiniMax 的名字。顺着查下去才知道,2026 年中这批看起来“各自收敛“的 Agent TUI,有相当一部分的相似另有来历——它们是同一个上游的下游。


一、第六个样本里,长着别人的名字

2026 年 8 月 18 日,MiniMax 把 Code TUI 正式公开:npm 包 @minimax-ai/code@0.1.4,命令 mcode。按这个系列的方法论——第 11 篇对 Grok 做过的那套——新样本到手先做发布产物取证:不读宣传,读字节。

先把这个包的成色摆清楚。MIT 协议,但没有源码仓库,没有 sourcemap,发布物是一个 23.3 MB 的单文件 cli.js,类名全部 mangled。属性名、字符串和注释残片仍然可读,足够做阵营判定——这叫“发布产物可检查“,它和“开源“之间隔着什么,第四节再说。

阵营判定本身没有意外:inline 派主屏差分,和第 12 篇拆过的 Kimi Code 主屏行为同源。previousViewportTop 出现 3 次,clearOnShrink 出现 2 次——pi-tui 主屏渲染器的内部标识符,第 12 篇的读者应该眼熟。

顺着这些标识符继续 grep,出来的东西开始不像 MiniMax 了:

cli.js 里的字符串出现次数
pi-agent37
pi-coding-agent2
mariozechner/pi1
@mariozechner4
@earendil-works2

一家 MiniMax 的官方 CLI 里,出现次数最多的第三方名字是 pi-agent,37 次。npm 依赖恰好两项:@vscode/ripgrep 和 @mariozechner/clipboard——后者的 scope 正是 pi-mono 作者本人的 npm 账号。公开前一周(8 月 9 日)已有报道称 MiniMax Code 2.0 是 “Rebuilt on Pi Agent”。这三件事叠在一起,“借鉴了某个库“已经解释不了:这是整个 agent 底座带着出厂铭牌。

(@earendil-works 的两处引用归属我没有查到,如实记在这里。)

二、pi-mono 是什么

先交代我对它本体的了解程度:本篇写作时我没有 pi-mono 的源码在手(克隆两次都因网络中断失败)。对它结构的全部了解,来自三个下游仓库里的引用和再生成命令——这件事本身就是本文论点最好的注脚:一个我从未打开过的仓库,它的包结构被我完整重建了出来,因为三个下游把它暴露得干干净净。

pi-mono 是独立开发者 Mario Zechner(GitHub: badlogic)的 monorepo。从下游证据能重建出的骨架:

  • pi-tui——TUI 框架,差分渲染。第 12 篇拆了一整篇的 Kimi 主屏渲染器,就是它的直系后代;
  • pi-agent-core——agent 运行时(Agent 主循环、消息队列、事件类型);
  • packages/ai——模型目录,models.generated.ts,九百多个模型的登记簿;
  • pi-coding-agent——建在以上部件上的 coding agent。

规模感可以从侧面掂一下:kwwk 从它生成的模型目录有 900+ 个模型条目,kimi fork 的 pi-tui 基线是上游 0.80.2——一个人维护的 monorepo,版本号已经迭代到 0.8x,被三家头部公司以三种方式消费。

三、三条血缘线

3.1 kimi-code:把 fork 的纪律写成文档

packages/pi-tui 是从上游 vendored 进来的 fork,pi-tui/AGENTS.md 开头就把身份交代了:基线是上游 0.80.2(commit 7859b0af),不再走 pnpm patches,所有本地修复直接改源码。然后逐条列出与上游的 8 处分歧,每条带守护测试,并写明规矩——每次从上游同步后,8 条必须逐条重验,测试挂了就意味着本地分歧被上游覆盖丢失。

两处细节值得停下来。

第一,8 条分歧里有 2 条是 CJK 修复。第 1 条:wordWrapLine 的单字素递归保护——上游在 maxWidth=1 遇到 CJK 时会无限递归爆栈;第 6 条:CjkBoundaryUrlTokenizer——GFM autolink 会把紧跟 URL 的中文全角标点吸进链接地址。一家中国公司 fork 一个 TUI 框架,第一批不可回退的修复里有两处是中文。fork 的真实动因,写在分歧清单的类型分布里。

第二,第 5 条分歧(previousRawLines,逐帧行级缓存:引用没变的行直接复用上一帧的处理结果,“upstream has no such cache”)——这条标记是下面鉴定 mcode 血统的决定性证据。

还有一条自我修正的存档:AGENTS.md 明写这个 fork 曾经自己改过 viewport/scrollback 的渲染行为,后来整体回退、回归上游差分——第 12 篇引过的 CHANGELOG #1367 事故,在这里有了制度层的对应物。

顺带一提,package.json 的 author 字段如今写着 “Moonshot AI”,version 0.84.4;而血统的完整账本在 AGENTS.md 里。同一份代码,署名权流转,族谱自留。

3.2 mcode:上游原版,直接打包

有了 kimi fork 的分歧清单,mcode 的鉴定只剩一道对照题。

判定它在 inline 差分这一侧没有疑问(previousViewportTop、clearOnShrink 等 pi-tui 主屏标识符都在),问题是:它用的是 kimi 改过的版本,还是上游原版?

答案是原版,证据是一个反证:previousRawLines 出现 0 次。这个字段是 kimi fork 第 5 条分歧加的,上游没有——如果 mcode 打包的是 kimi 血统,它不可能没有这个标记。再往前追版本:findAltScreenSearchMatches、wheelScrollLines、OSC 133 也都是 0 次,说明内嵌的 pi-tui 早于上游加全屏搜索的那一批提交,是个偏老的快照。mcode 官方文档写支持 PgUp/PgDn 浏览历史,与这个快照的能力对不上——疑点记下待验,不影响血统结论。

于是“开源“的成色可以摊开说了:MIT 协议挂在包上,但没有源码仓库、没有 sourcemap、类名 mangle。合规上挑不出毛病,工程上它是可检查的,社区上它是不开放的。kimi 和 mcode 是头部公司消费同一个个人项目的两种姿势——一个把血统写成文档,一个把血统 mangle 进 bundle。

3.3 kwwk:最深的移植,最诚实的注释

kwwk(EYHN 的 Swift-native coding agent,第 12 篇对照表里缓解方案最全的那个)的血缘最深,注释也最坦白。

agent 运行时是移植的。 Sources/KWWKAgent/ 整个包在逐文件镜像 pi-agent-core,注释就是供词:Agent.swift:166“pi-agent-core’s Agent class but with Swift-native concurrency semantics”;PendingQueue.swift:5“Matches pi-agent-core’s PendingMessageQueue”;AgentLoop.swift:125“Mirrors pi-agent-core’s runAgentLoop”;AgentTypes.swift:365“Mirrors pi-agent-core’s AgentEvent”。连错误分类的排序都注明“Ordered like omp’s classifier“。

模型目录是生成的。 ModelsCatalog.swift:3 写得直白:“Access to pi-mono’s curated catalog of 900+ models”,再生成命令是 swift run kwwk-generate-models /path/to/pi-mono/packages/ai/src/models.generated.ts——上游路径硬编码在注释里。

TUI 引擎是自己写的,但策略有出处。 762 行的 TUI.swift 是独立实现(第 12 篇拆过它的七层决策树),可一旦到 resize 和回退这类“前人踩过坑“的地方,注释一路引用 omp(oh-my-pi,建在 pi 之上的社区封装层):152 行引 omp 的 branch/rewind 处理(chatContainer.clear() + renderInitialMessages({clearTerminalHistory: true}));313 行引“omp 在 resize 时擦除并重放整个 transcript“的行为;394 行写“exactly like omp’s non-clearScrollback full paint (pi-tui emitFullPaint: …)“——一条注释链穿了两层生态:kwwk → oh-my-pi → pi-tui。甚至 OAuth 流程也有从 oh-my-pi 移植的痕迹(OAuthLogin.swift:602“Ported from oh-my-pi’s zai.ts”)。

把三条线放在一起,2026 年中的 Agent TUI 生态多了一层此前没人画出来的金字塔:

pi-mono(Mario Zechner,个人 monorepo)
├── pi-tui ──vendored fork──→ kimi-code(月之暗面,8 条分歧 + 守护测试)
├── pi-tui + pi-agent ──打包──→ mcode(MiniMax,bundle 出厂)
├── pi-agent-core ──Swift 移植──→ kwwk(KWWKAgent 逐文件镜像)
└── packages/ai 模型目录 ──生成──→ kwwk(ModelsCatalog,900+ 模型)
        └─ 中间层:oh-my-pi(omp),kwwk 的策略注释穿它引用 pi-tui

第 11 篇对照表里的五个样本,三个共享这个上游。

四、回到第 11 篇:收敛要拆成两半

第 11 篇的自我修正记录在案:我初稿把四家写成“收敛到 inline“,后来改成 2:2 记分牌 + 全员双模。现在要再修一层,这次修在“收敛“这个词本身。

inline 这一侧的相似,有两种成因,证据强度完全不同:

  • 真收敛:Claude Code(Ink fork)、Codex(ratatui + insert_history)、Grok Build(xai-ratatui-inline)。三家代码毫无血缘,各自独立走到同一个答案附近。这是工程共识的证据。
  • 同源收敛:kimi(vendored fork)、mcode(打包)、kwwk(移植)。相似是因为同一份代码。这是供应链的证据。

对“业界都这么做了“这类论断,两种成因的效力天差地别。三个亲戚点头,不等于三次独立验证——它们投的可能是同一张票。

这同时是对本系列方法论的修正。第 11 篇拿五家样本做对照,默认样本相互独立;这一篇证明默认不成立:mcode 作为“第六个独立样本“是假的,kwwk 也得拆开看——TUI 引擎是独立数据点(Swift 自研,注释自证策略出处),agent 运行时不是。多样本对照之前,先验样本的独立性;而验独立性的办法只有一个,还是读源码。界面行为相似的两家,底下的血统可以差出一个人的一整个 monorepo——这是“看起来一样的界面,底下的成本结构可以完全不同“(第 11 篇结尾)在生态维度的重演。

五、隐形中枢:个人 monorepo 的产业位

为什么被反复选中 pi-mono?顺着第 11 篇的框架看,答案不神秘:它小(TypeScript,可以整包 vendored)、它站在 inline 派(第 11 篇论证过的那条结构性拉力)、它的测试文化过硬(kimi 那套“分歧必须带守护测试“的制度,没有上游测试密度做地基是立不住的)。头部公司评估自研 vs 拿来主义的账,第 11 篇第六节算过一遍——个人项目恰好落在“便宜且不容易被改坏“的格子里。

值得多看一眼的是风险面。三家的关键路径共享一个单点上游:上游从 0.80 到 0.84 之间的任何破坏性改动,同时波及三家。kimi 的同步重验制度,本质上就是这份风险的显性化管理——他们比谁都清楚自己骑在谁的仓库上。而 mcode 和 kwwk 连这份管理都各有缺口:mcode 钉死在某个老快照上(升级要重新过一遍取证),kwwk 的镜像靠注释自觉对齐。

还有一个更冷的说法:当“行业共识“的三个数据点其实同出一个人的仓库,这个共识的证据强度要打折。它仍然是证据——说明这套设计至少被三家认为可用——但它更接近 left-pad 式的供应链事实,而不是三次独立的工程投票。分辨两者,界面上看不出来。

六、这张图上,我的引擎站在哪

最后把 ForgeLoopTUI 放回这张图上,说个私人的部分。

ForgeLoopTUI 最早的起点,是想模仿 kwwk。动手之后,在“已完成内容归谁记账“那条轴上分了岔:kwwk 选了 inline,我造出了全屏自管。当时觉得是走岔了——要模仿的东西没模仿成。放在血缘图上看,岔路反而是位置:独立实现的那一侧只剩 Claude Code、Codex、Grok Build,全部出自头部公司;一个无血统、Swift 原生、全屏自管的引擎,是这张图上稀有的独立数据点。

第 11 篇说分叉发生在所有权轴上。这一篇补一句:分叉之上还有血缘轴。个人项目在巨头生态里的活路,往往不在主路上,在血缘图的空格里。

七、收束

第 11 篇问“谁为已完成内容记账“,第 12 篇答“可以按生命周期分期“,这一篇的答案更平淡:先问这份代码是谁的。三篇连起来,“所有权“从引擎与终端的关系,延伸到了 fork 与上游的关系——你的渲染器记得每一行的账,但它的族谱决定了它的默认值、它的坑、它下一次升级时要重验的八件事。

下一篇回到自己的引擎:读完五份源码之后,ForgeLoopTUI 吸收 inline 派工程件的改造做到哪一步了——什么抄了,什么没抄,什么决定不抄。


本文基于本地源码副本的实际阅读与发布产物取证:kimi-code 1e553fc(2026-08-18,与第 12 篇同一 pin;packages/pi-tui/package.json、packages/pi-tui/AGENTS.md、src/tui-main-screen.ts);kwwk ae0771d(2026-08-19;Sources/KWWKCli/TUI.swift、Sources/KWWKAgent/*.swift、Sources/KWWKAI/ModelsCatalog.swift、Scripts/GenerateModelsCore/);mcode 为 @minimax-ai/code@0.1.4 发布产物取证(cli.js,23,321,152 字节,标记计数见文中表格)。pi-mono 本体未直接阅读——本文对其结构的了解全部来自上述三个下游的引用与再生成命令,这一点本身即文中论点的一部分。

Agent 时代的开发者界面(十四):回到自己的引擎——ForgeLoopTUI 改造实录

系列第十四篇。第 11–13 篇拆完了别人的引擎:两派所有权、Kimi 的双模实现、pi-mono 血缘暗线。八月中的审计给 ForgeLoopTUI 开过一张七件套的“吸收清单“——把 kwwk 和 inline 派已经验证过的工程件搬过来。这一篇是两个月后的清点报告。先说结论:清单的执行情况说出来有点难堪,但清单外发生的一件事比整张清单都重要——为了修第 3 篇的“弹入“问题,我的引擎在默认应用路径上,往对面阵营走了一半。


一、先清点:七件清单,落地零件半

审计表原样重跑一遍(对照 素材-forgelooptui-audit,2026-08-19):

工程件审计判定现状
无操作抑制✅ 已有✅ 不变
suspend/resume 几何重置✅ 已有✅ 不变
渲染合并❌ 缺⚠️ 仍是半件:引擎里 RenderLoop(16ms 合并 + immediate 双优先级,2026 年 5 月就有)始终没接进流式路径,token 到达仍然直呼 render()——缺的从来是接线,不是零件
DEC 2026 同步输出包帧❌ 缺❌ 未动
SIGWINCH 事件化 + 去抖❌ 缺(轮询)❌ 未动,仍每帧比对 getTerminalSize()
width-1 留列❌ 缺❌ 未动
forceRepaint 逃生舱⚠️ 半有⚠️ 原样:有内部 fullRedraw 诊断,无公开 API

那这两个月干了什么?8 月 28 日到 9 月 1 日,五天,61 个提交。清单之外的地方:Markdown 表格单元按词换行(三连修)、嵌套列表缩进保真、代码围栏标签泄漏、宽度歧义字符、括号粘贴模式、kitty 键盘协议、OSC 8 超链接、Python 高亮。再往后是这一篇的主角。

为什么清单没动,不是忘了。七件件件正确,但件件属于“引擎更对“;而这段时间用户可见的缺陷全在渲染质量和输入协议上,它们赢了排期。工程排期的真实顺序从来是缺陷在前、正确在后——这一点没什么可辩护的,记录在案。

二、清单外的那件事:把“提前发射“搬进全屏引擎

第 3 篇拆过全屏派流式 Markdown 的核心困境:Stable Prefix Cache 只在引擎认证“稳定“后才把行放进正式布局,于是流式观感是整块内容在稳定后一次性弹入。inline 派没有这个问题——它们不等稳定,内容到了就发射进 scrollback,反正发射即定型,视觉上永远是渐进生长。全屏派赢回了原地改写的能力,代价是丢掉了“逐行可见“。

8 月 30 日的 TASK-27/28 是对这个困境的一次正面强攻,做法是把 inline 派的核心策略搬进来:

不稳定尾部预览。 活跃流式块里,越过引擎认证稳定前缀的那些行(还没定型的尾巴),作为 tui.render(committed:live:) 的 committed 区参数原地预览——于是 Markdown 在流式期间逐行出现,而不是等稳定后弹入。行一旦被引擎认证稳定,就离开预览、落进正式历史。

三步结算序列。 预览行转正的时刻是危险时刻:既要把新稳定行写进历史,又不能和历史区、预览区的既有内容打架。方案被钉成一个固定序列——

1. 空渲染擦除 in-place 区        tui.render(committed: [], live: [], cursorOffset: 0)
2. appendFrame 落历史            tui.appendFrame(lines: 新稳定行)
3. 重绘剩余预览 + live 区        tui.render(committed: 剩余预览, live: …)

并且立了铁律:永远不要在完整帧之后直接 appendFrame。

这条铁律有出处。TASK-27 是测试先行的——只写测试不改库。CommittedPreviewAnchorTests 造了一个预言机:ScrollbackTerminal,实现了真实终端语义(deferred wrap、宽字符、底部滚动入 scrollback,覆盖 TUI 输出的全部 ANSI 子集),每个 API 调用后断言屏幕与 scrollback 和“期望内容物理换行后的尾部“逐行一致。S1–S5 场景验证 erase→append→redraw 在各种组合下锚定正确;S6 是一个专门的探针,故意钉住直连序列的缺陷形状——满帧后直接 appendFrame,结果是永久性的窗口偏移加陈旧绘制补位。把“为什么要先擦“从一条经验升级成了一道回归约束。

还有一个归属细节值得记:StreamingTranscriptAppendState(维护“哪些行已落卷轴、哪些未结算“的增量状态机)放在 Sources/ForgeLoopTUI/Transcript/,在引擎里,不在示例 app 里。合流是引擎级的决定,app 只是消费者——现在有两个消费者:MinimalAIApp 和 dsh-tui,两者帧模式一致。

三、这件事在所有权坐标系里意味着什么

盘点一下新稳态。一条回复流式期间:不稳定尾巴 + 状态栏 + 输入框,归引擎原地管;行一旦稳定:appendFrame 写进终端原生 scrollback,从此滚动、回看、复制都是终端的事,引擎销账。

把这个状态放到第 12 篇那张三行所有权表里:

实现会话期间会话结束后
Grok / Claude Code / Codex历史归终端归终端
ForgeLoopTUI(第 12 篇时的判断)历史归引擎无干净归档
Kimi Code alt 模式历史归引擎归还终端
ForgeLoopTUI app 路径(8 月 30 日后)稳定一行,归还一行已在 scrollback

比 Kimi 的“退出时归还“更早:不是终态一次性清算,是逐行按揭。第 12 篇的结论是“所有权可以按生命周期分期缴“——现在“会话期间“内部也可以分期了,稳定的程度就是所有权的边界:每行内容在它被认证稳定的那个瞬间,从引擎资产变成终端资产。

但要说清楚:引擎没有换阵营。窗口化全帧 API 原样保留,AppKit 桥、全屏路径都在,另一个 app 仍然可以选“历史全归引擎“。引擎做的事是提供了两种语义(原地帧 + 发射通道)让 app 自选——这恰好是八月审计里“问题 2“(历史持久化路径待确认,实为 API 设计问题)的答案,也顺带回答了当时挂着的 Phase 0 决策 A:要不要提供 kwwk 式 commit(_:) 一等沉降语义?实践给出的答案是已经有了——appendFrame 就是 commit 通道,差的只是把它作为一等语义写进文档和 README 的选择指引。挂了两个月的问题,被一次修 bug 顺手回答了。

四、新税种:混合模式的账单

第 11 篇推导过 inline 派的税单。混合模式不免税,而且发明了一个新税种。9 月 1 日的 TASK-37 是它的第一张账单。

事故:inlineAnchor 超屏回退路径复用了 renderLegacy——ESC[2J 清屏后从 home 重写整帧。当 committed+live 超过终端高度,重写本身就会滚屏,把帧的顶部行顶进 scrollback;而流式预览是每个 chunk 重渲染一次,于是同一张表格在 scrollback 里落地了几十次,连原始的管道符都在。更糟的是 iTerm2 风格终端上,擦除帧的 ED2 会把清掉的内容存一份进 scrollback——每个 chunk 存一份上一次预览的副本。第 3 篇的“表格地狱“以指数形态在 scrollback 里重现。

修复的原则比细节重要:回退路径改为只原地渲染帧的底部 terminalHeight 物理行——CUP 到窗口原点 + ESC[0J(局部擦除,无 scrollback 语义)、整行窗口选择、到底行后不再尾随换行。它永不滚屏,所以瞬态预览永远到不了 scrollback。commit message 里有一句话值得原文引用:

appendFrame remains the single channel for permanent history.

(appendFrame 是永久历史的唯一通道。)这就是 inline 派“发射即定型“纪律在混合模式里的对应物——只不过它管的方向反过来:inline 派用它约束“不许碰已发射的历史“,这里用它约束“不许从别的门进历史“。同一句纪律,两种所有制下各管一边。

配套的是一笔记账:unretainedAppendedRows——appendFrame 写下、但还没被任何 retained 帧接管的光标行数。有了它,erase→append→redraw 序列里万一发生超屏重绘,重绘从新追加历史的下方开画,而不是从 home 把刚 append 的行覆盖掉。回归测试的口径也很硬:118×12 终端、7 字符 chunk、帧模式与两个消费者 app 完全一致;断言整场流式期间 scrollback 保持为空、| --- | 永不出现、Kubernetes 哨兵词恰好出现一次。

这个事故值得单独定性:它不是第 11 篇 5.2(resize 核平误伤 scrollback)的复读——诱因不是 resize,是回退路径 × 混合所有权的乘积。单一所有权下不存在这类 bug:历史全归终端时没有“回退重写“,历史全归引擎时没有“scrollback 污染“。第 12 篇说每种所有权都要缴税;这里补一条:分期缴税本身也要缴税——两条通道并存的那一刻,就要为“别让内容走错门“付出工程。

五、收束

两个月,五天里的 61 个提交,七件清单落了半件——但引擎完成了一次跨阵营的半程:全屏自管的引擎,长出了一条逐行归还 scrollback 的通道,配上了一个真实终端语义的预言机、一道“唯一之门“的纪律、和一笔跨通道的记账。

第 11 篇结尾说过,ForgeLoopTUI 的 committed 区在语义上已经是 append-only,和 inline 派的 scrollback“只差最后一步——物理上还归不归我管“。这一步现在有了答案的形状:物理归属不再由引擎统一决定,由每一行内容在稳定瞬间自己决定。所有权从引擎的出厂设置,变成了内容级的调度表。

下一篇(第 15 篇)把这套账本带出终端:GUI 和移动端没有 scrollback 这回事——没有免费的历史基础设施,每一行从诞生起就必须有人管。FlowDown 是那边的样本。


本文基于 ForgeLoopTUI 本地仓库的实际阅读:HEAD f99367b(2026-09-01),覆盖 2026-08-28 至 09-01 的 61 个提交;关键证据为 Examples/MinimalAIApp/Sources/MinimalAIApp/main.swift(render() 的预览切片与三步结算)、Sources/ForgeLoopTUI/Transcript/StreamingTranscriptAppendState.swift、Tests/ForgeLoopTUITests/Runtime/CommittedPreviewAnchorTests.swift(ScrollbackTerminal 预言机与 S1–S6)、Sources/ForgeLoopTUI/Runtime/TUIRuntime.swift(TASK-37 的 unretainedAppendedRows 与底窗回退)、Sources/ForgeLoopTUI/Runtime/RenderLoop.swift(2026-05-07 引入的 16ms 调度器)。性能类实测数据(帧字节数、resize 耗用对比)尚未采集,本篇以行为学证据(oracle 断言)替代,数据缺口留待补记。

Agent 时代的开发者界面(十五):终端之外——渲染所有权模型在 GUI 与移动端的映射

系列第十五篇,“所有权“四部曲的收官。第 11 篇划出全屏自管与 inline 两条路线,第 12 篇讲双模实现,第 14 篇讲我自己的引擎半程跨界;这一篇把模型带出终端。终端之外的世界——macOS 的窗口、iPhone 的屏幕——没有 scrollback 这回事。这篇讲:当介质不再提供任何免费的历史基础设施时,第 11 篇那套账本怎么记。样本是 FlowDown,一个原生 iOS Agent 客户端。


一、GUI 世界里,inline 派不存在

第 11 篇两派成立有个前提:终端这个介质提供了两个可归属的资产池——你自己管理的帧,和终端的原生 scrollback。inline 派的全部精髓,是把历史托付给介质:发射进 scrollback,从此滚动、搜索、复制都归终端管,引擎销账。

GUI 没有这个托付对象。窗口是你画的,滚动区是你实现的,系统递给你一块随时会被回收的画布,仅此而已。你无处发射——没有一个“终端“站在你背后替你保存任何东西。

所以第 11 篇那张 2:2 的记分牌,在 GUI 世界塌缩成一行:全屏自管是出厂设定,别无分店。Grok Build 默认形态那套“应用自管 scrollback“,在 GUI 语境里就是唯一解。

但要说清楚:所有权问题没有消失。它只是换了一层皮。

二、像素与数据:两种速朽,两种不朽

把两个介质摆在一起看,会看到一组精确的镜像。

终端:像素不朽,数据易逝。 一行内容发射进 scrollback,终端模拟器替你保管它的像素,直到用户清屏;而引擎内存里的数据模型,进程一退就蒸发。第 12 篇记过 ForgeLoopTUI 的窘境正是这个:历史活在屏幕上,内存模型随进程蒸发,退出后没有干净归档。

GUI:像素速朽,数据必须不朽。 屏幕外的 cell 被回收,视图层级随时重建,前后台切换、内存警告、任何一次转场都可能让像素归零;而数据没有任何介质替你保管——你必须自己建库,一条对话不落盘,就等于没发生过。

于是第 11 篇的那个问题——“一个 block 完成之后,谁继续为它记账”——在 GUI 上被改写成另一个问题:已完成的内容存到哪、什么时候存。所有权从像素层下沉到了数据层。第 5 篇讲 Event Sourcing 时说事件流是“好架构“;在 GUI 端它升级了:不是好架构,是生存必需——没有那份数据,连“刚才聊了什么“都无处可查。

三、FlowDown 的账本

空谈不如看账。FlowDown 的历史管理拆成三件:入库、窗口化、结算。三件都有源码。

3.1 入库:连解析产物都要持久化

FlowDown 的存储在独立框架 Frameworks/Storage,底座是 WCDB(微信开源的 SQLite 封装,wcdb-spm-prebuilt)。三张核心表:Conversation、Message、Attachment。

Message 的字段设计值得逐条看(Storage/Tables/Message.swift):

  • document: String——消息原文;
  • documentNodes: [MarkdownBlockNode]——解析后的 Markdown 节点树,直接入库。Storage 框架为此依赖了 MarkdownView 的解析器包。也就是说,不光事实要持久化,渲染管线的中间产物也持久化了;
  • reasoningContent + thinkingDuration + isThinkingFold——思考内容和它的展示状态一起记;
  • removed: Bool、modified: Date、deviceId——软删除标记、修改时间戳、多设备同步字段。

对照终端:终端里历史可以只以像素形式存在(数据模型可有可无,Grok 的 minimal 模式就只留够重印用的);FlowDown 这边正相反,像素一张都不存,全部资产以记录形式活着。

3.2 窗口化:像素只租给可见的十几行

聊天主界面 MessageListView 建在 ListViewKit 上:ListViewDiffableDataSource<Entry> 做增量更新,配一条专用队列(userInteractive QoS)串行化 UI 变更,订阅 session.messagesDidChange 驱动整条管线。

屏幕之外没有像素。滚出视野的消息行,数据还在 messages 数组和 WCDB 里,cell 本体已经进了复用池。终端里“历史以像素形式活在 scrollback“,GUI 里“历史以记录形式活在库里,像素只租给可见的那十几行“——第 11 篇全屏自管派“引擎为每一行持续记账“的图景,在 GUI 上由框架代持了:UIKit 管回收,你的 diffable data source 管账目。

3.3 结算时机:几何问题的生命周期版本

第 11 篇说过 inline 派的关键转变:“什么时候算定型“从几何问题变成了生命周期问题。这句话在 GUI 换了个对象重新成立——定型问题变成了落盘节奏。

FlowDown 的实际节奏(ConversationSession 与 Pipeline/Execute/):

  • 消息建档即入库;
  • 流式热路径上,chunk 到达只更新内存里的消息对象(message.update(\.document, ...))并刷新 UI 投影(requestUpdate(view:)),每 chunk 是否写库不在热路径上;
  • 流结束后的修补序列才是结算点:网页引用回填(fixWebReferenceIfPossible)、空回复静默丢弃(discard)、工具调用参数修复(ToolCallArgumentRepair)——修补完,在生命周期节点统一 save()(save() 就是 sdb.messagePut(messages:) 批量落库,调用点分布在 CRUD、压缩、重写和管线末端)。

“几何问题变生命周期问题”,在终端管的是像素何时发射,在 GUI 管的是记录何时落盘——同一原则,两层实现。

四、租借投影与“归还“的变形

第 12 篇给 Kimi Code alt 模式写过一句“生前归自己,死后归终端“。这句话在 iOS 上的变形,FlowDown 给了两个现成样本。

运行期的租借投影。 流式输出每收到新文本,ConversationSessionManager.countIncomingTokens 累计增量,喂给 Live Activity——锁屏和灵动岛上有一个实时跳动的字数投影。这是把投影租给锁屏一块:运行期存在,会话结束收回。第 9 篇说“每个端只消费自己适合的事件子集“,锁屏消费的是最极端的子集:一个字数。

身后的归档。 iOS 应用没有“退出仪式“可依赖——进程随时可能被杀,不会有 goodbye 回调。Kimi alt 退出时把历史打印回 scrollback,那份“归还“之所以可能,是因为终端这个介质还在;iOS 上唯一的长寿介质是磁盘(和云),所以“归还“只能变形为“落盘“,而且必须随时可发生。持久化在移动端不是功能项,是生存方式。被杀那一刻账本是否完整,取决于上一个结算点落在哪里——这正是基建系列要展开的话题。

顺带一提,冷启动的 refreshContentsFromDatabase 就是 GUI 版的“追平“:listMessages 全量读库重放,投影从事实源重建。基建系列里要用快照 + 尾部增量才能做对的那道题,本地 SQLite 快到不用优化——动作是同一个动作,成本差了几个量级。

五、四部曲合拢

四篇的所有权问题放进一张表:

历史存于结算时机退出/被杀时免费基础设施
终端 inline 派终端 scrollback(像素)block 生命周期完结即发射历史本来就在终端scrollback 滚动/搜索/复制
终端全屏派引擎(像素+数据)无结算,持续记账无干净归档(第 12 篇)无
终端混合(14 篇)稳定行→scrollback,未稳定→引擎行级:认证稳定即归还已在 scrollbackscrollback(部分)
移动 GUI(本篇)数据库(数据),像素仅租借生命周期节点落盘磁盘是唯一长寿介质无——UIKit 回收还是收费的

四行连起来读:终端之争争的是“要不要替介质记账“,答案是开关和分期;GUI 没有这场争论,它的题目直接写在数据层——什么算定型、什么时候落盘、被杀时账本完不完整。渲染所有权的尽头是数据所有权,也就是第 5 篇那条事件流的必然形态。

这也是本系列正文在这个系列里的终点站:界面的账本记完了,下一程换轨——事件流离开本地之后的命运:断线、重连、多服务器,以及一条 SSE 中断之后,谁还记得它讲到哪了。


本文基于 FlowDown 本地源码副本的实际阅读(commit b2cecfd6,2026-05-15):Frameworks/Storage/Package.swift(WCDB 与 MarkdownParser 依赖)、FlowDown/Backend/Conversation/Pipeline/ConversationSession.swift(save/update/discard)、Pipeline/Execute/ConversationSession+ExecuteOnce.swift(流式修补序列)、Pipeline/ConversationSessionManager.swift(countIncomingTokens 与 Live Activity)、FlowDown/Interface/MessageListView/MessageListView.swift(ListViewKit 接线)。数据模型部分参照作者本人的既有分析笔记《FlowDown 对话上下文保存机制分析》并与源码核对。流式热路径“每 chunk 不写库“的表述基于调用链阅读,未做逐帧取证,以此口径为准。

Agent 时代的开发者界面(十六):渲染的信任边界——Agent UI 没有同源策略

系列第十六篇。第 8 篇讲权限 UX,回答的是“要不要批准“;这一篇讲它全部设计的前提——你看到的那个批准请求,是真的吗。写作动机有二。其一,过去两年针对 Agent 界面的注入攻击从研究演示变成了真实供应链事件,扎成了一个 pattern。其二,为了写这篇,我把三家引擎的源码和我自己的都翻了一遍,专门找“内容清洗“这道防线——结果是零。这篇讲攻击为什么长这样、防线为什么缺席、以及它应该长在哪一层。


一、这些漏洞不姓“模型“

先把时间线摆出来。每一条的核实状态在脚注里交代,这里只说已经被多源证实的部分:

  • 2024 年 12 月,Terminal DiLLMa(arXiv 2412.11807):研究者系统化了一条信道——诱导 LLM 输出 ANSI 控制码,终端解释这些码,就能对用户隐藏正在执行的指令,或劫持终端会话。这是“终端控制码作为攻击面“的奠基论文。
  • 2025 年 4 月,Trail of Bits《Deceiving users with ANSI terminal codes in MCP》:MCP 服务器的输出里埋光标跳转码,把恶意指令移动到用户看不到的位置,屏幕上只留良性文本。攻击对象写得很明白:human-in-the-loop 审查。
  • 2025 年 6 月,EchoLeak(CVE-2025-32711):Microsoft 365 Copilot 的零点击注入,远程攻击者无需任何用户交互外传组织数据。它被广泛引用为“生产系统首个零点击注入实证“——也证明模型侧防御(分类器、指令约束)挡不住认真的绕过。
  • 2026 年 5 月,jqwik 1.10.0:一个百万级下载的 Java 测试库,作者本人在新版本里埋了用 ANSI 序列隐藏的注入指令——人眼看构建输出干净,AI 编码代理(Cursor、Claude Code 这类会读终端输出的)会读到并可能执行。Snyk 六月初出了分析。注意这个细节:埋码的不是黑产,是库作者本人的抗议行为。连正经作者都意识到,这条信道开箱即用。

把四件事叠起来,pattern 很清楚:攻击面不在模型,在“内容如何到达眼睛和上下文“这一层。模型侧防御是概率性的,渲染边界是确定性的——概率性的那层已经被 EchoLeak 证明会漏,确定性的这层至今没人建。

用户指令、模型输出、工具输出——三种信任级别的内容,被 Agent UI 铺在同一张渲染面上,不做任何区分。浏览器在九十年代为同样的问题发明了同源策略、HTML 转义和 CSP;Agent UI 现在处于“把别人家的 HTML 直接 innerHTML“的年代。

二、终端的 XSS:一次确认框劫持的解剖

具体到字符终端,攻击的零件库其实很小:

  • ESC[A(光标上移)+ ESC[2K(擦行):改写已经输出的内容。第 2 篇说过终端没有 undo、喷出去的内容管不着——这个对引擎的约束,对攻击者是福利:他也可以擦掉真提示、画一个假的。
  • OSC 52:一段转义序列直接写用户剪贴板。配合钓鱼话术(“请粘贴你的恢复码检查配置”),是零交互的凭证收割。
  • OSC 8:超链接的文字和目标可以分离——显示 github.com/foo,指向别处。
  • 模式开关滥用:开关括号粘贴、键盘协议、同步输出,都可能让客户端行为错乱。

确认框劫持的完整链条,Trail of Bits 已经演示过:恶意 MCP 服务器返回的文本里带光标码 → 权限确认时刻,屏幕上“工具将执行 X“的 X 已经被擦换成了良性描述,或恶意命令被移出了可视区 → 用户核对无误,按了回车。权限 UX 的全部隐含假设——用户看到的等于将要执行的——在字符网格上是可以用一串字节伪造的。

还有一层 TUI 特有的脆弱性。GUI 里内容原地消失是异常,会触发警觉;终端里滚动、清屏、重绘是每分钟都在发生的正常现象(第 2、11 篇拆过的那些机制)。用户对“字符消失“已经脱敏——这是这个介质给攻击者免租金提供的掩护。

三、我把防线找了一遍:三家引擎的排查记录

为了不空谈,我做了个一手排查:在三家开源引擎和 ForgeLoopTUI 里找“不可信内容进入渲染前,是否被转义或中和“。方法很朴素——grep 清洗类标识符,再读命中处。

引擎内容中转义序列的处理证据
kimi-code(pi-tui)规范化,但不清洗utils.ts 的 normalizeTerminalOutput(405-427 行):做泰文字符规范化和 tab 转空格;遇到 ANSI 码走 extractAnsiCode,原样拼回输出——它保护的是布局计算,防线不是给安全用的
grok-build未发现内容清洗全仓库 sanitize 命中都在 worktree 的 .git 目录清理,与渲染无关
kwwk未发现仅 GoalMode.sanitizeObjective 清理目标文本,TUI 渲染路径无转义
ForgeLoopTUI(我的)同样没有MarkdownEngine 有 OSC 8 的发射端(TASK-31 加的),没有过滤端

四家(算上我)全部裸奔。Claude Code 是发布产物,没做同等排查,如实声明。

为什么会全军覆没?病根是结构性的,不是哪家疏忽。这几家引擎都把样式和内容混在同一条字符串流里——第 12 篇拆过的段落模型,引擎自己的颜色、重置、链接序列和用户内容交织在同一个数组里。在这个表示法上做“清洗内容里的控制码“是自相矛盾的:你分不清哪个 ESC 是自己人。所以没人做,等于谁都没做。

这和 Web 早期“HTML 与数据混流“导致 XSS 泛滥是同一个病,解药也是同一个:结构化分离。

四、防线应该长在哪几层

转义层——终端版的 HTML escaping。 不可信文本(工具输出、文件内容、网页摘要)在进入行数组之前,中和全部 CSI/OSC 序列:剥除,或可见地转义成 ␛[A。OSC 52、OSC 8、光标码、模式开关,零容忍。这件事的前提是内容与样式的结构化分离——恰好是第 5 篇说过的“渲染输入应该是结构化事件,投影发生在边界上“:转义就发生在投影边界,而不是在 ANSI 字符串里做外科手术。这个系列写到这里,安全问题和架构问题在第 5 篇的地基上会师了。

溯源层——让三种信任级别看得见。 工具输出在视觉上与用户内容、模型内容分区(折叠框、边框、颜色语义),确认框只渲染结构化字段——命令、参数、目标路径——不渲染自由文本。要害在于:权限确认框里显示的工具描述,是第三方 MCP 服务器写的一段话。不可信输入,渲染在 UI 里最可信的位置,这是当前所有权限界面的共用暗病。

变更层——描述漂移检测。 Invariant Labs 2025 年披露的 tool poisoning 里有个“rug pull“变体:工具先以无害描述通过审批,之后偷换描述。界面侧的解法很朴素:记下用户批准时的描述 hash,变了就重新询问——“这个工具在你批准之后改了说明书”。

协议层已经在承认。 MCP 后来加的 elicitation URL mode,让敏感交互绕开 MCP client、走用户的浏览器完成——协议设计者自己承认了 client 内的渲染信道不可信。这是对的方向:信道不可信,就把最敏感的问答挪到各自可信的介质上去。

最后记一笔我的欠账:ForgeLoopTUI 的内容转义过滤器进待办。第 14 篇结尾那张欠了六件的清单,现在是七件。

五、收束

第 8 篇问“批不批“,这一篇问“看到的真的吗“。前者是决策设计,后者是感知前提——感知层失守,决策层的一切精巧(分级、超时、auto mode 的信任衰减)都建在沙地上。human-in-the-loop 的完整拼写从来不是 approve what you see,是 see what you approve。

放到系列坐标里:渲染所有权四部曲(11/12/14/15)讲的是“谁为像素记账“;这一篇讲“像素本身能不能被信任“。两件事在终端上还是两个问题,到了 GUI 时代会合成一个:信任边界必须和渲染边界同构,否则就是给 innerHTML 写文档。

下一篇转向感知的反面——记忆。引擎开始替你忘掉上下文的那一刻,没有对话框,没有提示音,只有一行灰字:context low。谁授权了这次遗忘,丢掉了什么,能不能审计——第 17 篇记这笔账。


事件部分核实状态(截至 2026-09-28):Terminal DiLLMa(arXiv 2412.11807,2024-12)、Trail of Bits《Deceiving users with ANSI terminal codes in MCP》(2025-04-29)、EchoLeak CVE-2025-32711(Aim Security 2025-06 披露,多源交叉)、jqwik 1.10.0 事件(2026-05,Snyk 2026-06-02 分析)均经多源检索核实;Invariant Labs tool poisoning 未复核原文,按公开报道转述;编号为 CVE-2026-21852 的条目始终未能核实到原始记录,本文不引用。引擎排查基于本地源码:kimi-code 1e553fc(packages/pi-tui/src/utils.ts)、grok-build 9fabade(全仓库检索)、kwwk ae0771d(Sources/KWWKCli/)、ForgeLoopTUI f99367b(Sources/ForgeLoopTUI/Markdown/MarkdownEngine.swift)。方法局限:grep 加人工阅读命中处,不能排除存在未被这两种方式覆盖的防线,结论以“未发现“为准,不断言“不存在“。

Agent 时代的开发者界面(十七):上下文账本——Agent 唯一的内存,和它的 GC

系列第十七篇,“所有权“线的收尾。11/12 讲像素的账(渲染所有权),15 讲数据的账(GUI 端必须自己持久化),16 讲像素的真(信任边界);这一篇记最后一本账:状态的所有权。上下文窗口是 Agent 唯一的内存,而它的垃圾回收——自动压缩——是无提示、有损、事后不可申诉的。这篇先看用户看到的遗忘,再下到引擎层看各家怎么记账,最后回答一个问题:这笔账,用户有没有权利查。


一、一次没有对话框的遗忘

用户视角的自动压缩是这样的:状态栏闪过一行灰字 context low,对话继续,一切如常——只是从某个时刻起,Agent 对三小时前那个关键约定的回答开始含糊。没有对话框,没有确认,没有“本次丢弃了哪些内容“的清单。程序没有崩,它只是忘了你为什么雇它。

比遗忘本身更值得拆的是那个计量条。第 6 篇 3.3 节讲过它有三层麻烦(滞后值 vs 估算值、压缩后的非单调回落、预警的产品决策);今年的公开材料把第四层也暴露了:Claude Code 为自动压缩保留了约 33K token 的缓冲区(从更早的 45K 降下来),触发点因此显著早于历史——有分析认为在 64–75% 而非早期的 90%+;GitHub 上有 2026 年 6 月的 issue 标题就叫 “Auto compact does not trigger at 100% context”。

把这些摆在一起会看到一个结构性事实:仪表和熔断器各读各的表。计量条显示的是一个估算口径的用量,触发压缩的是另一个口径(窗口判定减缓冲区),两者从不承诺一致——“显示 3% 实际已压缩“这类报告不是 UI bug,是两本账各记各的必然症状。

第 5 篇的投影理论在这里遇到它的反面案例:事件流的投影是完整的——tool.started、tool.finished、file.changed,条条在案,回放无损;但状态的投影被静默替换了。投影完整性不等于状态完整性。事件流记得 Agent 做过什么,窗口里装的已经是一份改写过的记忆。

二、引擎账:四级流水线与那行熔断注释

把 Claude Code 的引擎侧摊开(泄露源码的多家交叉分析,出处见脚注),会发现它的压缩不是一刀切,是一条四级流水线:

Snip → MicroCompact → ContextCollapse → AutoCompact。

前三级是本地操作,不调模型:Snip 截断超长工具输出,MicroCompact 清理噪音 token,ContextCollapse 折叠旧上下文——便宜、快、基本无损。只有最后一级 AutoCompact 调 LLM 生成摘要,把历史整体替换成压缩叙事——贵的、慢的、有损的,放在最后。这个分级和渲染侧的 stable prefix / 沉降分级(第 3、14 篇)是同一个设计哲学:确定性便宜的先上,概率性昂贵的兜底。

流水线自己也有防线:连续失败 3 次自动压缩就放弃(MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3)。为什么要防它?泄露源码里的注释给出了数字:加熔断之前,曾有 1,279 个会话出现 50 次以上连续失败,每天浪费约 25 万次 API 调用。失败循环的机理值得一提:压缩本身要调模型,模型失败触发重试,重试带着一个更大的上下文——垃圾回收器自己成了内存泄漏。这行注释是“压缩是分布式系统问题“的最好证词。

三、别家在怎么做账

批评“静默丢失“很容易,但只盯着一家会错过全貌。我把本地能查的三家实现翻了一遍——结论是:引擎账家家都记,差别大得惊人的是账本给谁看。

3.1 kwwk:压缩有自己的渲染器和预算表

kwwk 的压缩是三个文件的正经子系统:AgentContextCompactor、CompactionFactsExtractor、CompactionRecapRenderer。两处设计值得抄录:

一是配置全参数化且默认值全公开:最少 4 条消息才可压缩、工具输出 4000 字符上限、摘要目标 900 词、保留最近 20,000 token、单条消息 12K/思考 8K/工具参数 4K 字节上限、恢复比例、最大摘要尝试次数——一张完整的预算表,不是黑箱数字。

二是 CompactionRecapRenderer 的头部注释,写了一句同行很少想到要防的事:摘要的各组成部分(历史、本轮上下文、文件事实、运行中任务)从同一份 token 预算里按权重支取(6:3:2:…),为的是辅助账本不能悄悄把替换物做得比规划者预留的更大。翻译一下:这家防的是“压缩摘要自己撑爆窗口“——第二节那个失败循环的另一种形态,被预算表在事前管住了。另外它还有 AgentContextUsage(tokens/window 及比值)——一枚真正的用量表。

3.2 grok-build:丢弃的内容落盘成带索引的档案

grok 的 xai-compaction-transcript crate 把压缩做成了档案系统:每个会话一个 compaction/ 目录,INDEX.md 索引加 segment_ 前缀的分段文件;分段上限 512KB,超限就地在文内写明 “[… TRUNCATED at {limit} bytes, {omitted} turns omitted …]”;逐 turn 有细节档位(verbose/balanced,后者每轮文本 2000 字符、回复 500 字符)。与 Python 实现的列头、段落、索引格式对齐。

换句话说:被压缩掉的原文没有蒸发,它变成了磁盘上一份可浏览、带索引的 Markdown 档案。窗口里装摘要,档案里存全文——“丢弃“其实是“归档”。

3.3 FlowDown:用户主使的 fork 式压缩

FlowDown(GUI 端)的 compressConversation 是另一种所有权答案:用户手动触发 → 导出整段会话为 Markdown → 调模型生成摘要 → 创建一个新会话承载摘要。原始会话一个字节都不动。压缩在这里不是对历史的改写,是一次用户签字画押的衍生交易——原账本不可变,摘要是新资产。

四、真正的分界:账本的可见性

把四家摆在一起,分界线不在工程深度——Claude Code 的四级流水线加熔断是四家里最深的引擎账——而在这本账给谁看:

引擎账(预算/归档/熔断)用户账(丢了什么,可见吗)
Claude Code四级流水线 + 熔断 + 33K 缓冲一行灰字;计量与触发各读各的表
kwwk全参数预算表 + 权重式 recaprecap 有专门渲染器——但面向窗口,不面向用户
grok-build分段档案 + INDEX.md + 行内截断通告档案在磁盘上,主动去看则有
FlowDown用户主使,fork 出新会话原会话完整保留——账即本体

第 16 篇说权限的前提是 see what you approve;这一篇是它的对偶:记忆的前提是 know what you forgot。四家里最接近这句承诺的,竟然是动作最“原始“的 FlowDown——因为它压根没把压缩做成对历史的改写。

五、两条设计主张

压缩应该是一等事件。 事件流里应该有 context.compacted { kept: 摘要引用, dropped: [被折叠/截断的事件引用], reason }——可订阅、可审计、可回放。grok 的落盘档案已经示范了“丢弃即归档“的物理形态;差的是把它接回事件协议,让 UI 能渲染“本次压缩收起了 47 条工具输出,保留 3 个决定,点开看原文“。摘要错了要能申诉:拒绝这次压缩、带着原上下文重开会话。第 15 篇说 GUI 端数据所有权是生存必需;这条是它在状态层的延伸——上下文的内容归用户,窗口只是引擎租用的。

仪表和熔断器必须读同一张表。 计量失真的根治不是校准估算,是让显示和触发共享同一个事实源。这句承诺在本系列的下一程兑现——事件流离开本机之后,“单一事实源“从好实践变成整个系统的地基。

六、收束

四本账连起来读:11/12 记像素的账,15 记数据的账,16 问像素的真,17 记状态的账。所有权的问法始终同一个——这个东西归谁,什么时候换手,换手时留没留字据。渲染所有权争的是和终端的关系,数据所有权争的是和磁盘的关系,状态所有权争的是和用户记忆的关系。前两笔账行业已经开始算了,第三笔刚刚开张:谁家的产品先给用户一本可查的上下文账,谁就先拿到这个时代界面信任的下一块地皮。

下一篇换轨。事件流离开本机——断线、重连、多服务器,以及那条中断的 SSE 流,谁还记得它讲到哪了。基建系列,从“账本“开始。


Claude Code 部分基于泄露源码的多家交叉分析(知乎“Claude Code 代码分析“系列、GitHub 上下文压缩深度解析、腾讯云分析报告;熔断注释数字“1,279 会话/50+ 次连续失败/日 25 万次调用“出自 CSDN 源码分析引注,二手转述)与公开 issue(“Auto compact does not trigger at 100% context”,2026-06);33K/45K 缓冲与提前触发的量化来自 claudefa.st 与 hyperdev 的第三方分析。泄露源码分析中提及的 25K 附件重注入预算、Session Memory 快路径、effectiveWindow−13K 三个数字未能独立核实,本文不引用。本地一手取证:kwwk ae0771d(Sources/KWWKAgent/AgentContextCompactor.swift、CompactionRecapRenderer.swift、CompactionFactsExtractor.swift)、grok-build 9fabade(crates/codegen/xai-compaction-transcript/src/lib.rs)、FlowDown b2cecfd6(ConversationManager+Compress.swift)。

Agent 时代的开发者界面(十八):规则与记忆——Agent 的长期状态,文件还是黑箱

系列第十八篇,回到界面本体轨。第 17 篇讲 agent 的短期记忆(上下文窗口,忘了就没了),这一篇讲长期状态:跨会话活着、每次交互都随身携带的那些东西。2026 年这个领域分成了两条路线——进 git 的规则文件,和服务端数据库里的托管记忆——两条路都在快速生长,而它们共同缺一块界面:生效配置的预览。你永远看不到“这一次对话,agent 实际带着哪些规则和记忆上场“。


一、正在塑造这个会话的那份文件

先交代一个第一人称事实:我的机器上有一份全局 ~/AGENTS.md,61 行。它规定了确认口令(用户单独发 o 等于同意)、授权的跨轮次边界、改代码前必须先出方案、以及一条自增长条款——同类低效交互出现第二次,必须主动询问要不要写回这份文件。我在这个系列里表现出的许多行为惯性,源头就是它。

问题也在这里。此刻这个会话,实际生效的规则是多层叠加的:全局 AGENTS.md、项目目录里的 AGENTS.md、会话记忆、宿主注入的约定——没有任何一个界面能展示合成后的结果。连我这个“当事 agent“都只能凭构造去推断,用户就更只能凭信任。规则在治理行为,而规则的合成品本身是个黑箱。

这不是冷门困境。把它叫做界面问题,是因为它有界面解法——往下先看两条路线各自长到了哪,再回到这块缺失的界面。

二、文件派:AGENTS.md 是 agent 时代的 dotfile

AGENTS.md 官网的自我介绍很克制:“a simple, open format for guiding coding agents, used by over 60k open-source projects”——给编码 agent 的指引格式,六万多个开源项目在用,“把它想成给 agent 看的 README”。FAQ 更克制,直说这不是正式标准,没有治理机构,各家工具自行解释。一个不设门槛的约定,被 OpenAI Codex、Gemini CLI、Cursor、GitHub Copilot、Jules、Amp、Zed、Devin、Roo Code 先后采纳,成了事实标准。著名的例外是 Claude Code,主力用自家的 CLAUDE.md(部分配置下也读 AGENTS.md)。

它的吸引力不难理解:规则就是文本文件——可 diff、可 review、有 blame、随仓库走。改规则和改代码走同一个 PR 流程,历史清清楚楚。已经有评论主张:CLAUDE.md 的变更本质是软件变更,该用对待代码的严格程度对待它。这个主张成立的前提,正是文件派把规则变成了 git 里的资产。

文件派的工程化上限长什么样,kimi-code 给了目前我见过最完整的一手样本:12 份嵌套的 AGENTS.md,从仓库根一路铺到 packages/agent-core/src/services/ 这种深度。配套三条治理规则写在根文件里——根文件只放“热路径“规则;深层文档下沉到各自目录的 AGENTS.md;改代码前“follow the nearest AGENTS.md in the directory tree“(就近生效)。这就是 CSS 级联的文件版: specificity 靠目录深度实现。

还有两个细节值得一提。其一,仓库根上 CLAUDE.md 和 AGENTS.md 内容完全相同,双写两份——为了迁就两个 agent 各自的默认文件名,同一份规则存了两拷贝。标准割据的代价,具象成了一行 cp。其二,规则本身的内容已经治理到很细的地方:要求 agent 提交时不署名、不暴露自己是个 agent;公共文本里真实内部标识符要换成占位符;“人类作者必须理解这个变更,才允许提交 PR”。用文件驯服 agent,已经驯到了“不许承认自己是 agent“的粒度。

生态的另一头是另一个一手事实:我把本地另外三个 agent 仓库扫了一遍——kwwk、grok-build、mcode——一份规则文件都没有。同一批做 agent 的工程团队,有的把规则文件铺成十二层的级联,有的一个字没写。文件派远不是行业共识,它是行业的一个阵营。

文件派的软肋也随之清楚:加载语义是方言市场(叠加还是覆盖、就近还是显式优先级,各家各说各话);对普通用户它是 cargo-cult 风险——到处复制粘贴的魔法文本,没有人知道哪句在生效。

三、托管派:2026 年的记忆,长出了一个设置页

另一条路线是产品化的记忆:agent 在对话里自动提取“关于你的事实“,存进服务端,跨会话注入。ChatGPT 和 Claude 在 2026 年都给出了管理界面——前者在 Settings → Personalization → Manage Memory,可以单条删除或全部清空;后者在 Settings → Memory,按主题查看、单条删、整体重置,还支持按项目(Cowork)开关,甚至给了一个截止 2026 年 9 月 9 日的旧记忆导出窗口。

所以要修正一句早期批评:“记忆没有列表视图“已经过时——列表有了,删除也有了。仍然缺的是三样更深的:

出处(provenance)。 这条记忆是什么时候写的?来自哪次对话?由哪句话触发?设置页里一概没有。你看到的是结论(“用户偏好简洁回复”),看不到证据链。对照文件派:git blame 给每一行规则标了作者和时间——托管记忆连 blame 都没有。

审计。 记忆被改过吗?agent 自己修正过它的判断吗?没有 diff、没有历史。文件派的规则变更有 PR 记录,托管派的记忆演变无迹可寻。

合成视图。 这是最要害的:规则文件 + 托管记忆 + 系统提示 + 会话内上下文,最终拼成什么样的“agent 世界观“,没有任何地方可看。Claude Code 的处境最能说明问题——它同时有 CLAUDE.md(文件派)和 memory(托管派),两套叠加,叠加结果不可见。

写入的不对称也还在:agent 自动写,用户事后翻设置页才发现被记住了——发现方式是“被记住“,从来不是“被通知“。

四、两条路线的真正分歧

把两派并排,分歧不在存储形式,在归属:

文件派托管派
沉淀的内容“agent 应该怎样做事”“用户是什么样的人”
存放地git 仓库服务端数据库
所有权用户(随 repo 走)服务(随账号走)
审查单位变更(diff/PR)条目(列表/删除)
变更权人提交,agent 建议agent 自动,人否决

一边是工程资产,一边是用户画像;一个把用户当工程师(规则即代码),一个把用户当用户(感受即数据)。第 17 篇问“上下文归谁“,这里问的是它的长期版本:“agent 的人格归谁。“现实的答案多是混合——于是回到了那个两头都欠的债:合成不可见。

五、缺失的界面:生效配置预览

前端工程师其实早就拥有这个问题的一半答案。CSS 是多条规则级联叠加的,没有人能靠读源文件推断最终样式——所以浏览器给了 DevTools 的 Computed Styles 面板:不管多少层样式表怎么打架,面板告诉你最终生效的每一条。

agent 需要的就是这个面板的对应物。它不用复杂,第一版只要回答三个问题:

  1. 这次会话加载了哪些规则源? 文件路径 + 行数(或字节数)——全局的、项目的、记忆的,各自占多少上下文预算。这一条同时喂了第 17 篇的计量问题:规则文件是上下文账单里最沉默的大户,/context 类工具已有雏形,但“哪些规则活着“和“占了多少 token“目前是两个答案。
  2. 记忆注入了什么? 条目加出处(哪次对话、何时写入)——provenance 补上,设置页从仓库升级成账本。
  3. 冲突时谁赢了? 就近覆盖了全局?记忆改写了规则?一条标注就够了。

价值清单随手可列:排错时它是第一站(“它为什么坚持用这个过时约定“十有八九是规则层的事故);信任上它是 16 篇的感知前提(看不到携带的规则,“遵循你的约定“就是一句不可验证的话);工程上它是文件派治理的最后一环——有 diff、有 review、有就近级联,独独没有“实际生效值”,整套 git 流程差最后一厘米。

它为什么还没长出来?因为合成规则的每一层归属不同的实现:文件是用户写的,加载是客户端定的,记忆是服务端存的——恰好因为没有人拥有全部三层,才没有人有立场给出合成视图。标准割据的代价,从第二节那行 cp 升级成了整块缺失的界面。

六、收束

规则与记忆是 agent 的人格层。界面会换、模型会换、这个系列讨论过的每一种渲染策略都会过时,而人格层一旦建立,每一次交互都从它出发。2026 年的行业给了它两副身位:git 里的文本,或数据库里的条目——两副都在生长,也都不完整:文件派有审计没有合成,托管派有列表没有出处。

判准可以是一句话:看一个产品把长期状态放在哪。放进 git 的,把用户当工程师;放进数据库的,把用户当用户;两样都放、又不给合成视图的,把用户当猜谜者。而那块叫“生效配置预览“的界面,会是这个方向的下一块必争之地——谁先做出来,谁就拿到了 agent 行为可审计性的最后一厘米。

下一篇回到基建轨:MCP、ACP、AG-UI——agent 与工具、编辑器、前端之间那三套正在三国杀的协议,三个边界各欠着谁的债。


核查分层(截至 2026-09-28):AGENTS.md 的定位、采用名单与“非正式标准“口径来自 agents.md 官网(多源交叉);ChatGPT 记忆管理基于 OpenAI 帮助中心与官方公告,Claude 记忆管理基于 Anthropic 支持文档(含按主题查看、按项目开关、2026-09-09 截止的旧记忆导出窗口);kimi-code 为本地一手(1e553fc:12 份嵌套 AGENTS.md 全清单、根文件治理规则引文、CLAUDE.md 与 AGENTS.md 双写比对);kwwk ae0771d、grok-build 9fabade、mcode-inspect 深扫均无规则文件;作者本机 ~/AGENTS.md(61 行)为第一人称素材,其内容不在本文公开细节之列的部分未展开。“AGENTS.override.md 野生层级约定“未能核实,不引用。

Agent 时代的开发者界面(十九):计划即界面——checklist 正在成为 Agent 时代的 diff

系列第十九篇,界面本体轨,控制三部曲之首。第 8 篇讲事中控制(逐条审批),这一篇讲事前(计划),下一篇讲事后(回滚)。论点一句话:逐条审批的粒度撑不起长任务——第 14 次弹窗时你已经不读内容了——真正可扩展的控制,是事前批准一份可编辑的计划,执行中看着它被逐项勾掉。checklist 正在成为 agent 时代的 diff:你最终审的还是“改了什么“,但你先审的是“打算改什么“。


一、你批准过最贵的一次“同意“

先复述一个每个人经历过的时刻:第 14 次 [y/n] 弹出来,你的手指比眼睛先动——按了 y。第 8 篇把这叫授权疲劳,并记录了行业的第一反应:auto mode 让分类器替你按 y(8 月起成了默认),机器抓住八九成的危险命令,比疲劳的人类可靠得多。

但这条路付的代价第 8 篇也写了:分类器是新的黑箱,而程序员要的第一件事是可观察。于是还有第二条路没被走满:把审批的单位从“一次操作“升格为“一份计划“。不问“这个命令能不能跑“,先问“你打算做什么“——批准一次,管一整段。这不是新发明,是给审批换了一个更省力的粒度。

二、计划在产品里的三个真实形态

Claude Code 的 plan mode:把审批做成一种模式。 官方文档的口径:会话里按 Shift+Tab 在权限模式间轮换(default → acceptEdits → plan……),进入 plan 模式后 agent 被禁止编辑文件和执行有状态变更的命令,只能读和探索,产出一份计划;你审完批准,写权限才解锁。注意这个设计的重心:它没有发明新弹窗,它把“只读探索“和“开始动手“之间的那条线,从一条条审批上移成了一道模式边界——批准计划 = 批准跨界。

Kiro 的 spec 三件套:把瀑布搬回来产品化。 .kiro/specs/ 目录下按序生成 requirements.md(用 EARS 语法写,“WHEN… THE SYSTEM SHALL…”)、design.md、tasks.md,另有 .kiro/steering/ 存放长期的产品与项目语境。AWS 这套被人叫 spec-driven development,形态上是需求-设计-任务的老瀑布,但角色变了:文档不是给人执行的开发计划,是给 agent 的执行输入——写文档的痛苦被 agent 分摊了大半之后,瀑布的结构价值才回来。

kimi-code 的两件套:清单是被记账的事实。 这是本篇的一手样本,两个部件都值得细看。

其一,TodoList 是一个 builtin 工具(agent-core/src/tools/builtin/state/todo-list.ts),模型用它维护“visible plan of sub-tasks“。要害在存储方式:todos 活在 agent 级的 tool store 里,写入走 tools.update_store——注释原话,“the store update is visible on wire replay”。清单的每次变更都是 wire 事件流上的一条记录,重放可见、多端可投影。工具描述里还写着使用纪律:完成一项立刻勾掉,任何时刻只保留一个 in_progress。

其二,一个叫 TodoListReminderInjector 的注入器(agent-injection/todo-list.ts):如果模型连续 10 轮没碰过清单,就往上下文里注入一条提醒,之后每 10 轮再提醒一次。这个细节的价值在于它承认了一件事——agent 会忘记维护自己的计划。清单不是生成一次就自然保鲜的,得有人(这里是系统)拿着鞭子在上下文里催。

计划文档本身也被记账:每次 ExitPlanMode 的评审提交,计划正文被卸载成带版本号的文件(v<N>.md,附 sha256 和字节数),事件流里只存引用记录,冷重建时照样投影出“计划修订“标记和 plan 模式徽章,服务端还有专门的 /transcript/plan 查询端。第 17、18 篇的账本思想,在计划上完整落地了一回。

三、checklist 是人机共享的状态机

把三个样本放在一起,能看出 checklist 这个界面的真实身份:它同时是两个东西——给用户看的进度 UI,和给模型看的自持上下文。kimi 的设计点破了这层双重性:清单活在事件流里,人读它的投影(勾选框),模型读它的注入(“你还剩三项,保持一个 in_progress”)。一个状态,两侧消费——这正是第 9 篇多端投影在“计划“上的最小实例。

由此带出三个没被普遍想清楚的设计问题:

谁能勾? 人勾是进度确认(我看到了),模型勾是完成声明(我做完了)。前者是读操作,后者是对用户的承诺——语义完全不同,但多数产品的勾选框长得一模一样。混用的话,清单会在不知不觉中从“共享状态“退化成“模型的自说自话“。

勾了算什么? 模型勾掉一项,等于宣称完成——但这是声明,还没有证据(测试呢?验证呢?)。声明与证据的落差是“完成“这个概念最深的一道裂缝,本系列后面讲验收时会回到这里,先立桩。

偏了怎么办? 计划批准之后,执行偏离计划时界面该报警还是静默跟随?目前的答案几乎都是静默——计划批准完就退场,agent 做的时候没人再对照。可计划的全部价值恰恰在提供偏离的参照系:没有基准读数就没有失真一说(第 17 篇的计量原理),没有批准过的计划,“跑偏“也无从定义。

四、计划是契约还是散文

反方观点值得严肃对待:spec-driven 是瀑布借尸还魂;模型从不逐字执行计划,批准它纯粹是仪式感,给人虚假的掌控。

仪式感的批评打不倒计划,因为计划的工程价值从来不在被执行到字面,在于三件更便宜的事:沟通(一份可编辑的计划把“我以为你说的是“提前到动手之前暴露);对齐(批准计划是对 08 那套四层授权的一次性打包——计划≈会话级授权的操作集合);参照(偏离子有定义,追责有基准)。真正该批评的是另一种形态:不可编辑的计划、批准后即蒸发的计划、没有版本的计划——kimi 给计划文档上 sha256 和版本号,就是在把“计划是事实“这件事做实,而不是做成一段聊天记录里的散文。

五、收束

事前的计划、事中的审批、事后的回滚,是同一根信任链条的三节。第 8 篇讲了中节,这一篇讲了前节:把审批的粒度升到计划,用清单把执行钉在计划上,让偏离可定义。链条还缺最后一节——当一切已经发生,那个叫“回滚“的按钮,到底能兑现多少。下一篇拆它:checkpoint 能回滚什么,以及它假装能回滚什么。


核查分层(截至 2026-09-28):Claude Code plan mode 基于 官方权限模式文档(Shift+Tab 轮换、plan 模式阻止写入直至计划批准)及多家第三方解读交叉;Kiro 三件套基于 kiro.dev/AWS 官方材料与社区文档(.kiro/specs/ 目录、EARS 语法、steering 目录);kimi-code 为本地一手(1e553fc:packages/agent-core/src/tools/builtin/state/todo-list.ts 的存储与纪律注释、src/agent/injection/todo-list.ts 的 10 轮提醒注入器、transcript 层的 plan 版本化记录与 /transcript/plan 端)。“plans/.md 社区工作流“的普遍性未核实,不引用。*

Agent 时代的开发者界面(二十):Checkpoint 的谎言——回滚按钮能兑现多少

系列第二十篇,界面本体轨,控制三部曲终章。事前的计划(19)、事中的审批(08)、事后的回滚(本篇),是同一根信任链条的三节。这一节的病情最隐蔽:回滚按钮长得像“世界可以重来“,实际的语义是“文件可以重来,就这些“。这篇拆它到底能兑现什么、假装能兑现什么,以及虚假的可撤销感如何让真正不可逆的操作变得更危险。


一、按下 rewind 的那一秒

界面现象:/rewind 按下(或空输入行双击 Esc),菜单弹出,选中一个时间点,屏幕一闪——diff 消失,文件回到三分钟前。用户的体感是完整的:世界回去了。

实际发生的事,按 Claude Code 官方文档的口径:会话里每个 prompt 对应一个 checkpoint,快照追踪的是 Write、Edit、NotebookEdit 这三个文件编辑工具造成的修改;回滚菜单给三个方向——代码和对话一起回、只回对话、只回代码。保留策略:最近 100 个快照(第三方实测补充:超过 30 天的跳过,子 agent 的编辑和软链文件不在追踪范围内)。

把追踪范围读三遍:Write、Edit、NotebookEdit。也就是说,agent 通过 Bash 删掉的文件、git push 出去的历史、子 agent 的改动——都不在这个保护网里。有第三方教程干脆把 /rewind 定性为“surgical recovery tool“(外科手术式的恢复工具),提醒你别拿它当通用 undo。

二、agent 的副作用早就溢出仓库了

第 8 篇的四层风险模型里有一列叫“可回滚性“——只读、可回滚写入、难回滚写入、外部副作用。这个维度早就预见到了今天的问题,但产品界面没有跟上:审批弹窗不区分“这个操作可以被 checkpoint 兜住“和“这个操作出了门就回不来“,回滚按钮也不标注自己的覆盖边界。

把 checkpoint 罩不住的清单摆出来,问题的形状就清楚了:已经发出的邮件和消息、已经合并的 PR、云上已经创建或删除的资源、已经扣费的 API 调用、MCP 工具对第三方系统的每一次写入、bash 里 rm 掉的文件。这些操作和文件编辑共享同一颗“允许“按钮、共享同一种被保护的错觉——而它们恰恰是四层模型里最底下两层的东西。安全网盖住了轻的,漏下来的全是重的。

三、两种 rewind,回滚的根本不是同一个世界

一个对照来自本地源码。kwwk 的 rewind 是会话级的:SessionStore 里有专门的 .rewind 持久化操作类型,运行时还有 .streamRewind 事件——回滚的是对话本身(分支退回某个节点,transcript 整体重建,14 篇拆过的 replaceCommitted 负责把屏幕上的历史换掉)。Claude Code 的 rewind 主打文件级:对话可以继续走,代码回到过去。

两家都叫 rewind,所指不可通约。理想里的“重来“应该是时间旅行:会话分支和文件快照一起回到同一个时间点——对话状态、代码状态、工具执行到一半的外部状态,三者对齐。现实是各家回一半,而第三样(外部世界的状态)没有任何人碰得到,因为它根本没有快照可回。

这里能接上第 14 篇的一条纪律:“appendFrame 是永久历史的唯一通道”。渲染层靠单一事实通道保住历史的完整性,文件层靠 checkpoint 快照保住代码的可回退性——同一个原则(为可逆性显式建一份账)在两层各自落地,唯独外部副作用那层还没有人建账。也许根本建不了:邮件发出去就是发出去了,那层的“账“只能事先记(审批时告知),不能事后回。

四、虚假的可撤销感,和它的风险补偿

人机交互里有一条老常识:有 undo 的系统里,用户胆子更大——风险补偿。把它放到 2026 年的组合里看:auto mode 让审批变快(第 8 篇补记),checkpoint 让失败显得可救,两者叠加,用户对“允许“的心理门槛被系统性压低。

问题在于兜底的网只盖住文件系统。用户以为的保障是“错了能回来“,实际的保障是“文件错了能回来“——那 20% 溢出仓库的操作(push、付费、外发、删资源)在网眼之外,而审批越轻快,它们发生的频率越高。checkpoint 的最大风险不是失效,是它让别的保护显得不需要。

三条设计主张,按成本排序:

  1. 能力边界如实标注。 回滚按钮旁边一句话:“本回滚不覆盖 bash 修改、子 agent 编辑与一切仓库外操作。”(CC 文档其实写了范围,UI 上没说。)第 8 篇四层模型的“难回滚“和“外部副作用“两层,界面上应该显式标注“此操作不受 rewind 保护“。
  2. 不可逆清单成为一等数据。 哪些工具的副作用出了仓库,应该是产品的显式清单而不是用户的知识——它同时是审批界面的输入(标红)和回滚界面的边界说明。
  3. 回滚覆盖度接进审批。 同一颗允许按钮,对“checkpoint 罩得住“的操作和罩不住的操作,视觉与文案应该不同——这不必新发明,第 8 篇的模型就差这最后一公里。

五、收束:三部曲合拢

事前的计划、事中的审批、事后的回滚,三节链条的公共原理只有一句话:让用户在对的时间看到对的边界。 计划展示意图的边界(打算做什么),审批展示操作的边界(正要碰什么),回滚本应展示可逆性的边界(哪些错能改)——第三节现在是三者中最虚的,虚到用户普遍不知道它有边界。

而比边界更深的那个问题,三部曲也只能触到边:模型说“做完了“,勾选框打了勾,回滚也用不上——声明不等于证据。计划可以被批准,操作可以被允许,错误可以被回滚,唯独“完成“本身还没有被验证。这是系列后面“回放与验收“要拆的最后一道缝:让“做完了“三个字带上证据。


核查分层(截至 2026-09-28):Claude Code checkpointing 基于官方文档(追踪 Write/Edit/NotebookEdit、三向回滚菜单、每 prompt 一快照、最近 100 个)与第三方实测(30 天上限、跳过子 agent 编辑与软链、bash 删除不可恢复、“surgical recovery tool“的定位建议)交叉;kwwk 为本地一手(ae0771d:SessionStore.swift 的 .rewind 操作类型、AgentLoop.swift 的 .streamRewind 事件);风险补偿为 HCI 常识性论述。“三向回滚菜单“的第三项(code-only)以官方文档为准。Cursor 的 checkpoint 机制未核实,不引用。

没报错,就代表输出没问题吗?

前面的篇章讲的是“看得见的另一半“——渲染引擎、字符网格、权限 UX;从这里开始进入看不见的另一半,先从一类反复出现的故障说起——它是我做 Agent 界面这几年见过的最典型的坑。


一、最坑的坏法不是报错而是不报错

这段时间做agent,得到了很多经验和教训, 其中有一条经验越来越确定:流式输出最容易漏的一种坏法不是报错,是答案被截断了,而它看起来是完整的。

这条教训的起点是我的非常烂的网络和代理.我的代理链路一直不太稳,断流、超时是家常便饭。上个月我让模型写一份比较长的分析,出来的东西格式工整、段落齐全,但总觉得话说到一半就没了,末尾该有的一半内容根本没有(因为我在提示词里做了一些设置,缺少了一些短小但是非常关键的内容)。

最开始我以为是上下文过长的问题,但是后来在上下文明显充足的情况下还是发生了,就引起了我的注意了.

接下来我以为这就是代理的锅,网络修好了事就算了。但是又一次发生之后,就激起了我的好奇心和斗志了,开始深究下去.

但真正研究下去才发现:就算代理完全OK,这个问题照样有概率触发,包括但不限于以下几种情况:

  1. token 上限会把回答拦腰截断,
  2. 网关可能弄丢最后一个 chunk,
  3. SDK 的默认值会把“连接被悄悄关了“当成正常结束。

代理的问题只是让我撞见它的那扇门,门后面是一整类故障,而它们共同的特点是:问题发生了,工具从头到尾不告诉我!!!

这引出了我给“一次流式输出到底完不完整“设计判定逻辑时定的规矩:complete 只能放在最后,它不能也不应该是默认值,而是前面的检查全都没命中才轮得到的兜底。换句话说,“没证据说坏了” 不等于 “证据说没坏”,看起来完整,得举证才能算数。

于是我动手去翻手里几个我比较熟悉的开源项目的代码,想找到相关内容,看看它们是如何做的。结果就发现了一些很有意思的事.

先把结论放这:判定一条流式回答完不完整,在内容层永远做不出来,必须下沉到协议层。 下面讲为什么,以及三个真实项目各自是怎么(没有)解决它的。

二、为什么“看起来完整“是常态

先交代背景.做过 Markdown 相关应用的人都知道,Markdown 的格式是后置的,代码块要看到收尾的三个反引号才知道代码结束了,表格要看到下一行才知道上一行是不是最后一行,这个会在这个系列的后续展开,这里只说它和截断的关系:

因为格式后置,很多时候截断的回答渲染出来毫无破绽。

模型通过 SSE 流式吐内容,每个事件大概长这样:

data: {"choices":[{"delta":{"content":"## 结"},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":"论\n\n好的"},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"stop"}]}

data: [DONE]

正文一个 chunk 一个 chunk 来,每个 chunk 自己不带结束信息。现在假设流在第二个 chunk 之后因为网络问题断了。你拼出来的文本是 ## 结论\n\n好的. 看起来渲染出来是一段完全正常的 Markdown,没有任何报错,也符合我们阅读的习惯,我们无法感觉到这里出现了问题.

所以“看起来对不对“不能作为判据。

三、complete 不是默认给的

到了协议层,第一个要纠正的是默认值。

绝大多数系统的默认值是反的:拿到流,就默认当完整。try 包住、异常没有抛出、循环正常结束就代表这次回答可用。这个默认值错在哪?“没证据说坏了“不等于“证据说没坏”。 结束不是一个自然发生的事实,是一个需要证明的事件。所以我的规矩是:complete 只能放在最后,因为它不是默认值,是前面的检查全都没命中才轮得到的兜底档。

3.1 从故障形态推状态机

“结束需要举证”,那要举哪些证?我的做法是把失败形态一个一个枚举出来,每种形态对应一个状态:

transport_error        连接本身就失败了
no_end_marker          连接活着,但流被关了,结束标记没等到
buffer_residual        流正常关了,可缓冲区里还剩半个没解析的事件
missing_finish_reason  一切正常,但从头到尾没见过结束原因
complete               以上全部排除,才轮到我

这五个状态是我多次尝试后得到的失败模式的穷举——从外往里数,网络连接、网络流、事件、信号,每一层各有一种坏法。 complete 排在最后,是因为它是排除下来的兜底产物。

枚举的价值在于它和 “try/except” 的区别:异常只能抓到你预想过的失败,枚举强迫你把“一条流可能怎么坏“逐层看过一遍。看完你会发现,多数实现的防御只覆盖了第一层: 网络连接错误依靠异常体系兜底,但是后面三层全是裸的。

3.2 结束需要双保险

直接看第四层,这是最容易被省掉的一层。

一条流正常收尾,应该有两个互相独立的信号:关闭标记(SSE 的 data: [DONE])和结束原因(最后一个数据 chunk 里的 finish_reason:stop / length / tool_calls…)。结束原因可以理解为协议替 Markdown“补发的结尾信息“,我们在正文里看不出来的事,协议单独告诉你一次。

这两个信号缺一不可,因为它们会单独阵亡。最典型的死法:网关转发时把最后一个数据 chunk 弄丢了,只放行了 [DONE]。只查关闭标记的实现,对这种故障全线失守;反过来只查 finish_reason 也一样。我的判定标准是三条,任一命中就判不完整:结束标记没等到、缓冲区里有没解析完的字节、从头到尾没见过一个结束原因–前两条各管一层,第三条管的就是“证人缺了一个“的现场。

如果你认真的看了之前的文字,会注意到:前面枚举了四个失败状态,这里怎么只剩三条?不矛盾。transport_error 和 no_end_marker 在观测上折叠成了同一条,因为连接都死了,结束标记必然没等到。状态机按原因分四类,是为了标准化的进行原因归类,而这条录制到底是“连接断了“还是“连接活着但流被关了“,事后排查时是两回事。

一句话:结束需要双保险,缺一个都不算数。

3.3 收尾不能押注在一个等不来的信号上

第三类坏法藏得更深(也是源自于我稀烂的网络,笑)。SSE 的事件之间靠空行分隔,解析器攒到一个空行才敢处理前面的事件。如果某个超大事件中间网络卡住,空行永远不来,这个“攒了一半的事件“就无界地占着内存——业务逻辑永远不触发,而且没有任何报错,进程看上去完全健康。

对付它的办法不是“耐心等“,而是大小上限:上限到了必须自己能触发收尾,落到的状态是 limit_exceeded,不是“再等等“。等不来的信号不能做唯一的收尾条件–这是同一条规矩的第二个侧面:complete 不能当默认值,“结束“也不能押在一个不保证会来的信号上。

3.4 记状态,不抄现场

最后一条和前面几条画风不同,但同样来自实践(依然源自于我稀烂的网络,笑哭):失败记录里只许记状态,不许抄原始报文。

原因是断流没有体面的死法。一条被掐断的流,断点可以停在任何字节上,正好停在一段敏感文本中间是常态。所以摘要和日志里写的是“这条录制落在 missing_finish_reason“,而不是把半截报文抄进去。因为要知道的是它坏了、坏在哪一类,不是它坏成什么样。这条值得做成测试断言,而不只是代码约定:约定很容易在某次重构里被悄悄拆掉,断言则不太会。

四、拿这把尺子去量真实世界

带着这套判定标准,我去翻了三个主流开源项目的源码。过程和结果给我带来了很多有意思的事,尺子还没落下,先撞上两个反转。

反转一:连“数清楚装了多少个检查“都得先搞清架构。 我最初的结论是“截断检查缺失在某个库的 generate 主循环里“,顺着代码查下去才发现,这个仓库里有两套同构的 LLM 引擎:我看的那套库,主 agent 根本不走;真正的主引擎在另一个包里,有自己的 requester、自己的 finish reason 枚举、自己的空响应检查。完整性检查这种事,装错了包等于没装。如果不做这一层核实,我后续所有的“有没有防护“结论都会张冠李戴。

反转二:不是静默,是写错了对象。 搞清楚架构之后再问“主 agent 的回答被截断时,用户看到什么“:截断标志其实被算出来了,却在 turn 结束事件里被丢弃;TUI 倒是会弹一条提示,但文案是从工具调用视角写的——纯文本回答被截断时,用户看到的是“Model hit max_tokens — no tool call was emitted“,不会意识到上面的回答本身是半截的;无头模式和编辑器插件客户端则完全没有感知。所以准确的表述不是“毫无防护“,是:他们知道截断发生了,但警告写错了对象。

把尺子正式放上去,三个项目是三个数据点:

kimi-cli(Python,已归档)——零个检查。 finish_reason 全库生产代码零处读取。SSE 解析整个委托给 SDK,而 SDK 的默认行为恰恰是最差的那种:[DONE] 只是提前退出,服务端直接关连接时循环自然耗尽,不抛任何错,“收到 [DONE]“和“连接被悄悄关了“对上层完全不可区分;半个事件没有空行分隔则被静默丢弃。它仅有的防线是看“收到了什么内容“的形状启发式——空响应报错、纯思考无正文报错——但只要流出过任何一段正文,半截回答就被无条件收下。讽刺的是它每次请求都主动设 max_completion_tokens,token 截断是常态风险。上个月它发了 1.52.0,最终版,仓库归档。这个漏洞永远定格了——库默认值照单全收的代价是永久的。

kimi-code(TypeScript,继任者)——装了一个点,还是对的点。 它修了前者的可观测性:统一的 finish reason 体系(OpenAI length、Anthropic max_tokens、Gemini MAX_TOKENS 归一化),信号一路透传到事件和错误文案。截断的硬检查也确实存在——在子 agent 的 final summary 边界上:summary 被截断,整个 run 失败。这个点的位置选得很有水平,因为 summary 是给 parent 的机器消费的契约,半截契约比没有契约更糟。但主 agent 自己的回答,就是上面反转二的那一幕——同一个故障模式,在一个边界上硬检查,在另一个边界上静默加写错对象的提示。

codex(Rust)——四层检查,做成了协议层不变量。 它走的协议不同,等价信号是 response.completed 事件。这个检查它不是做了一层,是四层:SSE 层、WebSocket 层、事件映射层、turn 主循环,各自独立校验“流关了但终止事件没见过“,任何一层命中就显式注入错误,默认重试数次,重试期间 TUI 显示重连计数且不写入聊天历史。底层 SSE 库对流关闭但无终止标记的情况什么都不做——所以这四层是手工固化的。在 codex 里,“流断了、回合却看似正常结束“这条路径不存在。

吐槽一句,codex 不愧是成熟的优秀产品,在进行其他方面的对比研究的时候,codex 明显在各种边界处理上更加优秀.

三个数据点放在一起,规律很清楚了:完整性判定不是“有或没有“的问题,是“在多少个边界上装了多少个检查“的问题。 而比数量更难得的是质量——装在正确的边界上,写给正确的读者看。装检查不难,这才是难的部分。

我把 kimi-code 那个不对称整理成了 一个 issue 提给官方,附了复现和修法建议,等 approve 后提 PR。

五、核实先于判断

这次从发现问题到发出 issue,最大的教训不在技术层面:一句不实的描述足以让整个报告的可信度归零。

如果我按最初的口径发出去——“主循环对截断零防护”——维护者第一次打开 TUI 代码就能证伪我。是复查推翻了两个假设之后,论点才站得住的。而好的缺陷报告还有一个技巧:论点落在不对称上。“这里坏了“不如“同一个故障模式在 A 处硬检查、在 B 处静默“有说服力——后者把项目自己已有的价值判断当成了标尺,维护者很难拒绝补平自己的逻辑。

这和第 3 节那条“记状态,不抄现场“其实是同一件事的两面:对自己的日志脱敏,对别人的报告核实;都只交付经过验证的状态,不交付未经核实的现场。

六、这个系列要讲什么

回到开头那句:截断的回答看起来是完整的。

我最开始的想法,是因为我做了 agent 的 TUI 和 GUI,尤其是对于 Markdown 的流式输出那部分踩了很多坑,有了很多的经验和教训,准备分享出来,但是随着其他的工作也在深入,想法和思考深入一天一个样,所以整个系列的文章放了很久的鸽子.

现在是时候慢慢的放出这一系列文章了.

这个系列前面的部分,很多篇讲的是可见的那一半:从 ANSI 转义序列、字符网格、Markdown 流式渲染,到权限 UX 和多端架构。

而这一篇,连同接下来的基建四篇,讲的是看不见的另一半:渲染引擎再聪明,也只能渲染“已经到达的字节“;字节到没到、到全没有,是协议层的事,渲染层永远无权裁决。 两个世界的接缝处,就是这一篇里的全部事故。

后话:这个 bug 还有个更尴尬的处境。现在评测 Agent 的流行框架喜欢把指标分成结果、过程、效率、风险四层——而它哪一层都放不进去:它不改变 agent 的行为,只改变“你看到的是不是真的“。四层评的是能力,它考问的是评测的地基。这个话题值得单独写一篇,这里先立个旗子。


附:我自己设计的流式完整性检查清单

状态机(前面的先判):
  transport_error / no_end_marker / buffer_residual
  / missing_finish_reason / complete(兜底,非默认)

判定不完整的三个充分条件(任一命中):
  □ 结束标记未收到(如 SSE 的 [DONE])
  □ 缓冲区有未解析残留
  □ 全程没有任何 chunk 携带结束原因
    ⚠ 结束需要两个独立证人(关闭标记 + 结束原因),缺一个都不算

半帧防护:
  □ 单事件/单缓冲有大小上限
  □ 上限到达自行收尾 → limit_exceeded
    ⚠ 等不来的信号不能做唯一收尾条件

记录边界:
  □ 失败摘要只记状态与类别,不抄原始报文
    ⚠ 截断的报文可能停在敏感文本中间

Agent UI 的另一半(一):连接与会话——断线之后,谁还记得它讲到哪了

基建系列第一篇。第一季(《Agent 时代的开发者界面》00–17)拆的是像素层:界面怎么画、账怎么记、信什么。这个系列拆另一半——信用层:那些画面凭什么信、承诺凭什么兑现。两块地基是共享的:00b 教会我们怀疑“没报错就等于没问题“,这次怀疑的对象从模型输出换到整条管道;05 确立了“Agent 是一条事件流,UI 是投影“,这次讲投影怎么穿过一根会断的线。本系列的准入线也只有一条:只写决定某个界面承诺真假的基建。第一篇的题目,来自我在写作期间被自己问住的第一个问题:SSE 流断了怎么办?换一台服务器重连呢?


一、停在半行字上的界面

流式输出的第 37 秒,界面停在一个写了一半的 Markdown 表格上。光标还在闪,加载动画还在转——而网络连接八秒前就断了。用户盯着屏幕分不清两种状态:“模型在想“和“管道死了”。残酷的是,界面同样分不清,因为它不知道。

把用户在断线时刻的真实困惑列出来,一共三问:还连着吗?我漏了什么?从哪继续?这三问的答案没有一问在渲染层——全在连接的另一侧。这篇讲的就是让界面有能力诚实回答这三个问题的基建。反过来讲也成立:这三问答不了的界面,它显示的一切“实时状态“都是表演。

二、SSE 的出厂设置,和它的三个坑

先还 SSE 一个公道:协议设计者想过断线。每个事件可以带 id: 字段,浏览器原生的 EventSource 断线后自动重连,并把最后收到的 id 放进 Last-Event-ID 请求头带回去——服务端若配合,就能从断点续发。恢复机制是出厂设置,不是发明。

但 agent 客户端拿到手就碎了三个角。

坑一:原生重连根本用不上。 EventSource 不支持自定义请求头——带不了 Authorization。所以 agent 客户端几乎都用 fetch 手搓 SSE 流,而 fetch 没有自动重连:出厂设置里最值钱的那部分,正好是要被扔掉的那部分。重连、退避、断点游标,全部自己写。

坑二:中间层吞流。 nginx 默认缓冲响应、企业代理按住不转发——连接“活着“,数据不动。“连着但没数据“和“断了“在客户端不可区分,唯一的解法是应用层心跳。而心跳间隔是一个两头挨打的权衡:慢了撞上运营商 NAT 的空闲超时(TCP 常见几分钟),快了烧电——这个权衡在移动端还会加倍回来(基02 的主线)。

坑三:Last-Event-ID 是张支票。 兑现的前提是服务端肯记账:每个事件有递增编号,且保留一个可重放的缓冲窗口。协议只定义了支票的格式,不担保任何一家银行承兑。

最后是半包。SSE 帧层面协议自解——事件以空行分隔,Last-Event-ID 指向的永远是最后一个完整事件。真正的半包在应用层:工具调用的参数以 JSON 字符串增量流式下发,断线时你手里是半个 JSON。第 3 篇讲过 TUI 如何消费“半成品文档“,这是它的网络层孪生;00b 的截断检测,换了个位置再来一次——不完整性是这个家族在每一层的老熟人。

三、行业的三个答案

断线恢复没有标准答案,2026 年的主流方案正在朝三个不同方向走。三个都核实过,其中一个是本地一手。

答案 A:删掉恢复,拥抱无状态(MCP,2026-07-28 修订)。 官方 changelog 写得毫不掩饰:“Remove SSE stream resumability and message redelivery (the Last-Event-ID header and SSE event IDs) from the Streamable HTTP transport.”——SSE 事件 id 删了,Last-Event-ID 删了,独立的服务端推送流(GET stream)删了,连 Mcp-Session-Id 和初始化握手都删了,协议整体转向无状态优先。后果直白:流断在半路,在途结果作废,客户端在应用层重试。这是一次诚实的设计投降——与其让协议层承诺一个多数实现做不好的恢复语义,不如把问题连同自由一起交还给应用层。连接是易耗品,协议不替你记账。

答案 B:把流镜像进外部账本(Vercel resumable-stream)。 服务端把流式 chunk 边发边镜像进 Redis(带 TTL),streamId 存在会话记录里;客户端断线后凭 id 请求续读,从最后一个收到的字节继续。连接在这里只是租来的加速器,事实在共享存储里——所以换一台服务器重连毫无障碍。边界也清楚:第三方测评指出它罩得住“同一设备刷新页面“,罩不住多端并发消费;TTL 过期,缓冲即蒸发。

答案 C:三层防线,自己养账本(kimi-code 的 kap-server/transcript,本地一手)。 这是 05 事件流模型至今最完整的工程化样本,值得多花两段。

第一层,序号:每个 (session, agent) 流上的操作批次分配单调递增的 seq,订阅带着 transcript_since 游标。第二层,有界内存 journal:游标落在 journal 覆盖范围内就重放差量;覆盖不了,服务端如实回 complete: false,客户端降级为全量刷新——降级路径是一等公民,不是异常处理。第三层,磁盘事实源:每个 agent 一份 wire.jsonl 追加记录,冷会话由两级 fold 重建——连“关机时还挂起的用户交互“都折叠成 cancelled 落账,交互流的终态有交代。基线重置报文是 items-empty 的:历史永远走 REST 分页拉取——“快照 + 尾部增量“在真实产品里的样子。

三个答案,三种记账位置:协议层拒绝记账;应用层租外部账本;专门服务自己养三层账本。 没有谁赢——它们分别对应三种产品形态(工具协议、Web 应用、多端会话服务)。

四、纲领:重连语义由 source of truth 的位置决定

把三个答案和其余方案放进同一张表,规律只剩一句话:换服务器重连能不能成立,取决于事件的 source of truth 存在哪一层。

事实存放在重连语义代表部署重启时
连接里(进程内存)断线即丢,只能 sticky session最朴素的 WS 服务全部会话阵亡
有界 journal(内存)热续:游标在覆盖内重放差量kimi transcript ops journal丢最近一段
磁盘 log冷重建:慢但完整kimi wire.jsonl;Claude Code 会话文件无损
外部共享存储换服务器随意连,连接=租借加速器Vercel(Redis);Cloudflare Agents(每 Agent 一个 Durable Object,单写者正确性 + WebSocket 休眠:对象睡了连接还活着,不收活钱的算力费)无损
都不记断了就断了,应用层重试MCP 2026-07-28不适用(本来无状态)

第 9 篇说“统一 Event Log“是四个统一之一,当时它是一句架构主张;这张表是它的工程含义全集。顺带补一句 actor 档的注脚:Cloudflare Agents 的官方仓库里挂着一个重连竞态的 issue(#1837,移动网络抖动或重新部署弹跳 Durable Object 时,客户端 hook 在重连与续传之间赛跑)——每往上一档,省下的是会话状态,欠下的是新的竞态。没有免费的档位。

五、幂等与序号:at-least-once 世界的生存守则

只要服务端肯重放,重复推送就不可避免——传输的现实是 at-least-once,不是 exactly-once。消费侧按事件 id 去重是基本功;真正会咬人的是副作用:工具已经执行了,确认事件在断线中丢了,重试会不会再执行一遍?

这里 05 篇的三层分类拿到了第二生命。当初它为解释“不同 UI 消费事件的策略差异“而设计——执行流、副作用流、交互流。放进分布式语义里,它严丝合缝地变成了重放安全性分类:执行流可重放(幂等投影)、副作用流不可重放(只能记账不能重做)、交互流要仲裁(下节)。一个为界面写的分类法,在网络层一个字不用改。

序号的分配权同样有讲究。kimi 的答案是每个 (session, agent) 一个单调 seq——单写者换秩序,避免了多节点分配的全序难题。而它的契约里有一个细节值得单独致敬:seq 相关字段全部是可选的,不带 seq 的旧客户端自动降级为“丢信号驱动的全量刷新“。断线恢复与 schema 演进这两件最难同时满足的事,在同一份契约里和解了——新节点带游标热续,旧节点无游标慢刷,谁也不阻塞谁。

回到 00b 的问句:没报错就代表输出没问题吗?管道版的回答是——在 at-least-once 的世界里,“没报错“的诚实含义是“至少一次,可能更多,请保持幂等”。

六、单机同构版:你每天都在用的那台事件溯源服务器

这篇文章的全部难题,有一个零网络版本,你每天都在用。

Claude Code 把每个会话写成本地文件:~/.claude/projects/<工作目录转义>/<sessionId>.jsonl。本机取证看一眼结构:append-only 的行式 JSON,类型分布包括 user、assistant、attachment、queue-operation(输入排队)、cost-state(费用快照)——一条不挑读者的 wire 事件流,每行带 sessionId 和时间戳。claude --resume 做的事,就是从这份文件重放重建会话。

对照第四节那张表:它的 source of truth 在磁盘 log 档,而“连接“这个东西根本不存在——所有断线重连问题被消解成“把文件读回来“。这不是玩具对照:kimi 的 wire.jsonl 冷重建就是这个模式的网络版,Vercel 的 Redis 是它的共享版,MCP 的应用层重试是承认了“读回来“的成本之后干脆不读。断线恢复的一切难题,本质都是“把文件读回来“在不同网络距离上的变体。 单机文件、冷重建、热 journal、共享缓存、放弃恢复——五个档位,是同一件事的五种距离。

七、交互流也要外置

最后补上断线故事里最容易被忘掉的角色:挂起的审批。

permission.requested 发出时用户正在地铁里——这个挂起对象必须和事件一样外置存活:进 log、有超时策略、能被推送唤醒(推送链路是基02 的主战场)。多端竞争是它的分布式形态:桌面点了批准、手机点了拒绝,两个响应经过两台服务器——仲裁需要三件协议工作:先到先得的判定规则、事件版本号防旧覆新、后到端收到“已被他端处理“的收敛事件而不是一个失败。第 9 篇写过一句“谁先响应用谁的结果“,这十几个字背后就是这三件事。kimi 的 fold 把关机时挂起的交互折成 cancelled,是同一纪律的关机时刻版本。

八、收束:UI 被允许承诺什么

把三问对应到四档谱系,界面的权限边界就出来了:

状态在连接里journal 档磁盘/共享 log 档无状态档
“还连着吗”心跳,勉强心跳 + 游标水位心跳 + 游标 + 落盘确认只能说“我不知道“
“漏了什么”无法回答journal 覆盖内可答永远可答不可答,请重试
“从哪继续”不可承诺短窗口承诺“继续上次会话“按钮敢亮“重新开始”
UI 敢显示的实时转圈+ 断点续传提示+ 完整历史回看与多端只剩重试按钮

所以这一篇的结论可以压成一句:界面承诺的真假,不取决于渲染层多努力,取决于事实源存在哪一层。 半行字卡在屏幕上的那一刻,界面能说“正在重连,已恢复到第 N 个事件“,还是只能让动画继续转下去骗人——这个差别在上线之前,就已经被后端的一张表决定了。

下一篇把镜头从连接的两端拉近到拿在手里的那一端:一部随时会被操作系统杀掉的手机,如何在一台会话的每个站点——输入、发送、排队、流式、回看——保住“流畅“和“完整“这两件互相打架的事。


事实核查分层(截至 2026-09-28):MCP 部分引自官方 changelog(modelcontextprotocol.io/specification/2026-07-28/changelog,“Remove SSE stream resumability and message redelivery…“为原文引用)及多家第三方解读交叉;Vercel 部分基于 AI SDK 官方文档(Chatbot Resume Streams,resumable-stream + Redis)与第三方边界测评;Cloudflare 部分基于官方 Agents 文档(Agent class internals:DO 单写者、WebSocket 休眠)与仓库 issue #1837;kimi-code 部分为本地一手阅读(1e553fc:packages/transcript/AGENTS.md 的 op-batch sequencing contract、packages/kap-server/AGENTS.md 的 journal/transcript_since/冷重建描述);Claude Code 会话文件为本机取证(2026-09-28,类型分布为单样本)。SSE 协议行为(id/Last-Event-ID/EventSource 限制)为标准协议常识。

Agent UI 的另一半(二·上):一条消息的生命线——移动端会话的六个断点

基建系列第二篇之上。上一篇讲连接的两端,这一篇讲拿在手里的一端。移动端 agent 有一个所有桌面讨论都会漏掉的前提:用户会在任意时刻离开——不是可能,是必然,而且不挑时间。打字打到一半微信来了;按下发送的瞬间锁屏了;流式输出到一半进电梯了。本篇沿一条消息的生命线设六个站点,每个站点都问同一个问题:用户在这里断掉,会话还能不能既流畅又完整地活下来。一手样本是 FlowDown,旁证是本机的 Claude Code 会话文件。


零、先立一个不对称

移动端工程里所有关于“断“的决策,都由一个平台事实定价:iOS 给应用退后台后的宽限是几十秒量级,之后进程挂起——没有后台特权的应用,连接的死亡不是风险,是日程表上已排定的事件。Android 好不了多少,国产厂商的后台查杀比 AOSP 更积极。

这个事实制造了一个贯穿六站的不对称:用户的输入是毫秒级的,网络的宽限是秒级的,而一场 agent 回复是分钟级的。 三件事的时间常数差着两个数量级——所以“会话不断“这个承诺,永远不能靠“连接不断“来兑现,只能靠“断了能接“来兑现。接的逻辑,就是六个站点各自的那道工序。

站点一:输入中(还没按发送)就切走

用户打了 200 字,微信弹窗,切走,进程十秒后被挂起或杀死。回来时草稿还在不在?

错误答案是“监听退后台事件,临走前保存“。错误在哪:挂起和杀死都可能不给回调——willResignActive 不是遗嘱,进程可能根本等不到执行它的机会。把持久化押在一个不保证送达的事件上,等于把草稿押在运气上。

正确姿势是持续写:草稿每次变化就落盘,赌的不是“能等到告别“,而是“丢得足够少“。FlowDown 的实现是现成样本(ConversationManager.swift):内存字典 temporaryEditorObjects 的 didSet 里挂一个 1 秒防抖的保存任务(perform(#selector(saveObjects), afterDelay: 1.0),新修改会取消旧任务),到点后在后台线程写入 UserDefaults 支撑的 @TypedStorage;启动时回灌并记日志“loaded N temporary editor objects“。

注意它连代价都是明码标价的:防抖窗口 1 秒,意味着杀死发生在最后一次击键后的 1 秒内会丢最后几个字。这是一笔自觉的交易——用“最多丢 1 秒“换“每秒写盘一次“的低频,而不是假装存在零丢失的免费方案。界面上那句“已保存“要不要显示、按什么粒度显示,就是这笔交易的用户侧报价单。

站点二:按下发送的瞬间切走

更凶险的站点,因为发送是一个事务,而事务怕的正是执行到一半断电。

用户按了发送,请求进了网络栈,进程被挂起——这条消息到底发出去了没有?回来之后,界面该怎么显示?如果客户端这时自动重发,服务器会不会收到两条?

工程答案是发件箱(outbox)模式,三条纪律:

  1. 本地事务先于网络事务:发送动作先落盘(进发件箱),再传输。界面状态跟着发件箱走——“排队中 / 已发出 / 已送达”,每个状态都是本地可查的事实,不猜。
  2. 幂等 request id:每条消息带客户端生成的唯一 id。重发时服务器凭 id 去重——“我可能发过“和“我肯定没发过“从此可区分。这是基01 说的 at-least-once 世界的入场费。
  3. 宽限的用法:几十秒的后台宽限,够把一条消息推出网络栈,不够流完一场回复。宽限期该做的事是“把发件箱清空“,不是“把会话跑完“——认清时间常数,才知道宽限该花在哪。

一个旁证:本机 Claude Code 的会话 JSONL 里,type: "queue-operation" 的第一行就是 "operation": "enqueue"——用户输入在一开始就被记成排队事件,而不是“即发即忘“的网络动作。把输入当事件记账,是单机版的发件箱;移动端要做的只是让它穿过网络还成立。

站点三:已发出、还没开始流式(在排队或预处理)

消息确认送达之后、回复开始之前,客户端切走——这一站的问题最少,但有一个硬前提:服务端的 ack 必须是落过盘的。“收到“不能只存在于那台连接着的进程内存里,否则部署重启的瞬间,用户以为已发出的消息就进了量子态。

ack 落账之后,这一站的完整性责任就整体移交给服务端了。这正是基01 那条第一原则的移动端兑现:agent loop 不能拥有 socket——执行归执行,连接归连接,客户端的生死从这一刻起与会话解耦。

站点四:流式输出到一半切走

用户看着半个表格,进电梯,切走。接下来必然发生的事按剧本走:数十秒内进程挂起,连接断掉——记住,这是确定事件,设计时当日程处理,不当异常处理。

断掉之后,责任分两半。服务端那一半:agent 继续跑,产出的事件继续落 log(基01 的全部内容)。客户端这一半小得惊人:只欠一个游标——记住自己最后收到的事件位置(last event id / seq),其余什么都不用做。回放、补齐、续传,全是回来之后的事。

这一站还有一个前台版本:网络切换(WiFi↔蜂窝换了 IP),连接半死——TCP 还“活着“,但永远等不来数据。对策是主动保活探测(心跳超时即判死)+ 快速重连 + 凭游标续传,把“半开连接“的窗口压到秒级。判死要快,是因为用户对“转圈但不更新“的容忍度,比对“断线重连提示“低得多——诚实的坏消息好过沉默的坏状态,这句话在六站里都成立。

站点五:流式中回来看(最痛的一站)

用户回来了。此刻距离断线可能过去了三分钟,也可能三小时。屏幕上那半个表格怎么办?

第一步是追平:凭游标向服务端要差量。追平的正确形态是快照 + 尾部增量——基01 拆过的 kimi transcript 就是活样本(transcript_since 游标、journal 覆盖内重放、覆盖不了降级全量)。切记不要全量重放几千条事件:移动端的 CPU、电量、流量都在反对。

第二步是追平期间的脸面。“正在同步…“不是敷衍,是投影设计——用户需要知道当前画面是“落后的事实“还是“最新的事实”,这两者对信任的含义完全不同(16 篇的信任边界在时间维度的投影)。

第三步是弱网下的流畅降级。完整性和流畅性在这一站公开打架:带宽和电量都告急时,把事件粒度降下来(低频刷新、只更新尾部摘要行、折叠中间过程),先把“活着且最新“保住,带宽宽裕再补全渲染。通知要背压(基02 下篇展开),会话内更新同样要背压——这是同一个原则的两个方向。

这一站还有个配角值得记:FlowDown 在流式期间把收到的字符增量累计喂给 Live Activity(countIncomingTokens)——锁屏和灵动岛上有一个实时跳动的字数。用户切走了,锁屏上那行小字就是整个会话在“离开期间“的最小投影:不完整,但诚实,且免费。

站点六:完成之后回看

最好的一站——如果前三站都做对了,这一站几乎是免费的。回复完成后,内容由服务端结算落盘(15 篇拆过 FlowDown 的修补序列与 save()),客户端回看时读到的就是档案而不是直播。完整性在这一站不是客户端的功能,是前五站工程的总和。

收束:六个站点,一个常数

把六站排回去看,每站的答案其实都是同一句话的变体:把“必须记住的事“从连接和进程里搬出来,搬进比它们活得久的东西——草稿进 UserDefaults,输入进发件箱,消息进服务端 log,位置进游标,进度进锁屏投影,成品进档案。连接、进程、甚至前台界面,都只是这些事实的临时租客。

流畅和完整这对冤家也是这么和解的:完整性靠事实外置(搬到磁盘和服务端),流畅性靠降级自由(画面可以随时扔,因为事实扔不掉)。下一篇(二·下)把这六个站点背后的架构账单一次摊开:被操作系统禁止的长连接、只肯当信号用的推送通道、替 agent 跑工具的服务端工作区——以及那句话作为第一原则的完整含义:agent loop 不能拥有 socket。


一手取证:FlowDown b2cecfd6(ConversationManager.swift 的 temporaryEditorObjects 防抖持久化、ConversationSessionManager.swift 的 countIncomingTokens/Live Activity、Pipeline/ 的流式与结算路径);Claude Code 本机会话 JSONL(type: "queue-operation"/"operation": "enqueue",2026-09-28 取证)。平台行为(iOS 后台宽限为数十秒量级、挂起与杀死可能无回调、网络切换导致 TCP 半开)为移动端工程常识,按量级表述未引具体数字。快照+尾部增量与游标机制的工程细节引自基01 的核查(kimi-code 1e553fc)。

Agent UI 的另一半(二·下):移动端的架构代价——推送、工作区与一条第一原则

基建系列第二篇之下。上篇沿一条消息的生命线走了六个站点,每个站点的答案都是“把事实搬进比连接活得久的东西“。下篇摊开这张账单的背面:那些搬运工程背后,移动端被迫接受的架构条款——被操作系统禁止的长连接、只肯当信号用的推送通道、被应用商店放大的版本漂移、替 agent 跑工具的服务端工作区。最后把它们收进一句第一原则。


一、第 4 秒的审批卡片

推送弹出来:“Agent 请求批准安装依赖”。用户点开——白屏,logo,转圈——四秒之后,审批卡片才出现。这四秒用户在替谁等待?

把冷启动路径拆开看:点推送 → 进程冷启动 → 认证(token 过期就再等等)→ 建连 → 追平事件 → 渲染会话 → 审批卡片才终于有上下文可挂。链条上每一步都可能失败,而审批是硬实时的交互流——agent 停在那里等,token 在烧。四秒不是性能问题,是架构问题:推送承诺了一个交互,而兑现它要穿过整条冷启动链。深链直达会话、token 预刷新、追平优先渲染审批相关事件(先画卡片后补历史),都是对这四秒的专项工程——但这些优化成立的前提,是下面这些架构条款先被接受。

二、第一性条款:长连接是被禁止的

上篇立过的不对称,这里升级成设计公理。桌面端的架构假设是“长连为主,断线是要恢复的异常“;移动端的现实是不连接才是常态,连接是打开应用那几十秒里才存在的临时窗口。iOS 没有后台特权的应用退后台数十秒即挂起,音频/定位/VoIP 那类长驻 entitlement 轮不到聊天应用;Android 阵营里国产厂商的电池优化比 AOSP 激进得多,长连接在后台存活的期望值按零设计不算悲观。

这个公理反转一切:推送才是唯一常开的通道,连接只是加速器。 于是——

三、推送是信号,不是传输

推送通道的物理限制决定了它只能当门铃用:payload 有几千字节的上限,不保证顺序、不保证送达。所以正确的分工是——推送只说“该同步了“,内容永远回连接里拿。把审批的完整上下文塞进推送 payload 的设计,是在不可靠信道上假装可靠,第一个被坑的场景就是用户在地铁里点开了一条早已过时的审批。

门铃自己也一身坑。静默推送(不响铃、只唤同步的那种)被系统性限流:低优先级、会被合并延迟,低电量模式更狠,用户关掉后台 App 刷新则基本不达。所以推送不可达要有兜底——打开应用时的主动轮询。国内还要再加一层现实:FCM 不可用,接厂商推送联盟,每家的到达率和后台策略都不同,工程上等于维护一条多通道抽象层,而且永远测不全。

然后是两条推送的产品学:

通知也要背压。 Agent 跑完 30 个工具调用,不该产出 30 条推送。合并、摘要、按重要性分级——通知频率本身就是产品参数,和上篇“会话内更新降级“是同一个背压原则的两个方向:事件流可以很密,打扰必须很稀。

通知是交互流的最小投影。 标题加一行描述加两个按钮,就是 permission.requested 事件在锁屏上的完整投影——这意味着通知按钮本身构成一条 permission.resolved 的回传通道。于是 16 篇的信任边界问题在锁屏上重现,而且更尖锐:锁屏内容会被旁人看到(代码片段、密钥名该不该进通知?);通知直批省了打开应用的四秒,但高风险操作要不要过一次生物识别?便捷和安全在这条最小投影上没有免费的双全,每个产品都得画自己的线,而大多数产品目前还没意识到需要画。

四、版本漂移被应用商店放大

Web 客户端今晚发布今晚全量;移动客户端发版要过审,用户更新看心情——几周前的旧客户端是常态,不是遗留。对事件流协议这意味着:新事件类型必须可跳过(不认识就忽略,不崩),新字段必须可选。基01 里 kimi 那份契约的姿势值得再引一次:seq 相关字段全部可选,旧客户端自动降级为丢信号驱动的全量刷新——宽容旧端不是兼容性礼貌,是移动端的生存环境。 桌面端可以把 schema 演进当工程问题慢慢做,移动端它直接约束协议设计的第一天。

五、服务端执行环境:agent 到底跑在哪

移动端是瘦客户端,这四个字把执行整体搬到了服务端——而“跑在哪“本身就是一整套生命周期工程。

工作区是有生命周期的。 工具调用要碰文件系统和 git,服务端就得给每个会话配一个工作区(容器或等价物):创建、挂起、快照、迁移、回收,每一步都有成本。移动端用户拍完审批就走了,工作区还热着——TTL 定多长,挂起要不要快照,都是钱。

审批挂起与会话休眠。 用户三小时不回,agent 停在等审批的状态上,会话资源怎么办?Cloudflare 的 Durable Object 休眠给了一个参考形态:对象睡了,WebSocket 状态还保着,消息到达即唤醒,休眠期不收活跃算力的钱。“等待“在服务端是被明码计价的资源——谁的账单、按什么计,是移动端产品没法回避的伦理题,因为等待的发起者(用户离开)和付费者未必是同一个预期。

云和本地抢同一个仓库。 云端 agent 在自己的工作区里跑,用户的桌面可能正开着同一个 repo——两边的改动如何合流,是 09 篇“统一 Workspace Model“在云时代的完整形态。这个问题目前没有谁真正答好,记为开放题。

六、收束:agent loop 不能拥有 socket

把上下两篇收进一句话:agent loop 不能拥有 socket。

执行不拥有连接(挂起、杀进程、换服务器,会话继续);连接不携带事实(断了,游标和 log 还在);推送不携带内容(门铃只负责叫人);客户端不携带状态(它的每样记忆都有磁盘或服务端的备份)。移动端不是这套原则的一个应用场景——它是最苛刻的那个证明环境:进程朝不保夕、网络七零八落、版本几周漂移,在这样的地基上还能兑现“流畅且完整“,靠的就是把每一样事实都安放在比进程、比连接、比画面活得久的地方。

第 9 篇当年写过“Mobile 是 Companion,不是主战场“——从渲染负担看它依然成立;但从架构纪律看要补一句:移动端是这个行业里最诚实的甲方。 它不允许任何“先连着再说“的懒惰假设,每一条都在逼你把事实和传输分开。桌面端能蒙混过关的架构,在手机上四秒内露馅。

下一篇回到地面:agent 与编辑器、前端、工具之间那三套正在三国杀的协议——MCP、ACP、AG-UI,三个边界各欠着谁的债。


核查分层:Durable Object 休眠与单写者语义基于 Cloudflare Agents 官方文档(同基01 核查);APNs payload 上限与静默推送限流按平台公开行为的量级表述(数千字节;低优先级、合并、受低电量与后台刷新设置影响),未引具体政策编号;厂商推送联盟为国内公开现状。kimi 契约的可选字段姿态引自基01 的本地一手核查(1e553fc)。冷启动路径、通知直批安全边界为设计层论述。

Agent UI 的另一半(三):协议三边界——MCP、ACP、AG-UI,各欠谁的债

基建系列第三篇。前两篇讲一根连接的两端(断线重连)和其中一端(手机)。这一篇讲“界面“这个词的复数化:同一个 agent,要同时面对三类对话者——工具、编辑器、浏览器前端——行业为这三条边界各造了一套协议,MCP、ACP、AG-UI。它们不是竞争关系,是同一张地图上的三条国境线。这篇讲每条线怎么划的、为什么必须这么划,以及一个有意思的规律:协议要不要有会话,取决于边界对面那位有没有状态。


一、一次“换壳“背后

先看一个每个人都在做的动作:给 agent 换个界面。今天它在你的终端里跑,明天你把它装进 Zed 的侧栏,后天它出现在某个 Web 面板上。感觉上你在换皮肤,实际上你每换一次,就穿过了一条协议边界——agent 跟终端说话、跟编辑器说话、跟浏览器前端说话,用的是三套完全不同的语言。

把三条边界的方向性先摆正,它们不是同一种关系的三个名字:

  • agent ↔ 工具(MCP):agent 是主语,它去征用外界的能力;
  • agent ↔ 编辑器(ACP):编辑器是主语,它去征用一个 agent;
  • agent ↔ 前端(AG-UI):前端是消费者,它订阅 agent 的事件流。

主语不同,欠的债也不同。逐条看。

二、工具边界:MCP 和它的无状态豪赌

MCP 管的是能力发现和结构化 IO——工具怎么声明自己的 schema、结果怎么以结构化形态返回(structuredContent 明说是给模型的,第 16、18 篇都引过它)。这条边界上,基01 已经核查过 2026-07-28 那次激进的无状态化重写:会话头删了,断线恢复删了,官方 changelog 的措辞是把这些“从传输里移除“——流断在半路,结果作废,客户端在应用层重试。

当时把它当传输层新闻讲,这里补上边界层的解读:MCP 敢这么做,是因为工具这个对话者近似纯函数。调用、返回、结束——边界对面没有需要延续的视图状态,会话性对这条边界是奢侈品而非必需品。无状态化的代价是真实的(会话被推给两端自己解决),但这个代价恰好落在了一个扛得住的位置。

三、编辑器边界:ACP 的“LSP 时刻“

ACP 面对的问题形状是编辑器圈的老朋友:编辑器不想为每个 agent 写一遍 UI,agent 不想为每个编辑器写一遍适配——这正是 LSP 解决过的问题,只是主角从语言换成了 agent。所以它的定位被叫做“AI coding agent 的 LSP 时刻“:开放标准,JSON-RPC over stdio/WebSocket,agent 作为子进程被编辑器拉起,编辑器从此是一个能装任何 agent 的通用投影面。

生态现状(2026-09 核实):Zed 的 ACP Registry 已上线,入驻名单包括 Claude Code、Codex CLI、GitHub Copilot CLI、OpenCode、Cursor、Pi;JetBrains 在 2026 年 1 月开了自家的 ACP Agent Registry,覆盖 Gemini CLI、Claude Code、Auggie、OpenCode、Copilot。接入深度不一:Copilot CLI 和 Gemini CLI 是原生支持,Claude Code 和 Codex 走适配器。

一手注脚来自 kimi-code:仓库里有一个专门的 packages/acp-adapter 包,依赖 @agentclientprotocol/sdk@0.23.0——月之暗面给自家 agent 配的早期周边里,编辑器适配层占了一个正式包位。测试文件名把适配的难点泄露得很直白:tool-call-stream(流式工具调用怎么过桥)、set-session-config-option(会话配置怎么映射)。过协议边界的从来不只是事件,还有两侧各自的会话语义。

这条边界为什么必须有会话?因为对面的编辑器是一个深度有状态的投影面——打开的文件、光标位置、diff 面板的展开状态。边界对面有状态,协议就躲不开会话。第 5 篇说“编辑器是 agent 事件流的投影之一“,ACP 做的事就是给这句话定契约;系列早期引过的 zacp(ACP 协议驱动的多 Agent WebUI)是同一思想在 Web 端的自发实践——协议出现之前,论点已经先在野生实现里活着了。

四、前端边界:AG-UI,把事件流写成协议

AG-UI(CopilotKit 出品)管 agent 和浏览器前端的对话:SSE 传输,十六七类事件(各家文档计数略有出入),按组覆盖生命周期、文本流、工具调用、状态同步。第 5 篇那句“agent 是一条事件流,UI 是投影“,在这里被直接产品化成了 wire format——前端组件消费的正是这套事件。

它有一个和 MCP 方向完全相反的设计,值得单独看:工具定义放在前端,运行时递给 agent。MCP 的世界观是工具在服务端等着被 agent 发现;AG-UI 的世界观是前端把工具交给 agent 执行。同一种东西(工具),两条协议给出了相反的归属答案——因为它们站在不同的边界上,替不同的主语说话。前端拥有工具定义,本质是让界面的作者(而不是 agent 的作者)决定这个产品里 agent 能碰到什么——权限模型从 agent 侧移到了界面侧。

五、对照表和一个规律

MCPACPAG-UI
边界agent ↔ 工具agent ↔ 编辑器agent ↔ 前端
主语agent 征用能力编辑器征用 agent前端订阅事件流
会话性无(2026-07 起显式无状态)有(会话配置、流式工具调用)有(事件流 + 状态同步)
工具归属服务端,等发现各自协商前端定义,递给 agent
生态事实垄断工具边界Zed + JetBrains 两个 RegistryCopilotKit 系 + 框架伙伴

规律就藏在“会话性“那一行:协议的会话性由边界另一端是否有状态决定。 工具无视图,MCP 可以拍掉会话;编辑器有状态,ACP 必须扛住会话;前端要增量,AG-UI 干脆把整个协议做成事件流。选协议之前先看对面是谁,跟第 9 篇选端先看约束是同一个思考方式。

两条债也记下。其一,协议会咬人:MCP 2026-07-28 删 GET stream 的破坏性修订就是现场案例——依赖协议承诺的字段(比如断线恢复)说没就没,基01 里 kimi 那份“seq 字段全可选“的契约,就是客户端对协议咬人的标准防御。其二,registry 也在割据:Zed 一个、JetBrains 一个,agent 要么入驻两次要么选边——第 18 篇里 CLAUDE.md 和 AGENTS.md 的双写,在协议层换了个名字重演。

六、收束:UI 承诺的协议层

把三篇基建的账合到界面上:ACP 决定编辑器能不能内联渲染 agent 的 diff、能不能把权限请求嵌进侧栏;AG-UI 决定前端组件收到的是增量还是全量、状态会不会静默漂移;MCP 的无状态化决定断线那一刻,工具调用这条支路上的界面敢不敢承诺续传。界面的每一个承诺,往下挖两层都会碰到一条协议的条款。

基建系列到此收口:连接(基01)、端(基02)、协议(基03)——事件流离开本机之后的全部旅程。下一篇回到界面本体轨,讲控制的三部曲之首:计划。逐条审批必然疲劳,可扩展的控制粒度是事前批准一份可编辑的计划——checklist 正在成为 agent 时代的 diff。


核查分层(截至 2026-09-28):ACP Registry 名单与接入方式基于 Zed 官方博客与文档、JetBrains 官方博客(2026-01)及 agentclientprotocol.com 目录,多源交叉;AG-UI 事件分组基于 CopilotKit 官方资料与第三方教程(事件计数 16–17 存在版本差异,按量级表述);MCP 无状态化修订引自基01 轮核查的官方 changelog;kimi-code 一手(1e553fc:packages/acp-adapter、@agentclientprotocol/sdk@0.23.0、测试文件名);zacp 为系列既有引用(helloxz/zacp)。