渲染一个 agent,和渲染一场聊天不是一回事
编码 agent 发出的是工具调用、diff、终端输出和任务清单,不是消息。把这个界面做出来之后我们才明白,真正有意思的设计问题是「它还在思考时该显示什么」。

渲染一个 agent,和渲染一场聊天不是一回事 💬
我们做了一个在手机上和编码 agent 对话的界面。显而易见的起点是聊天 UI,而这个显而易见的起点在一个具体的意义上是错的:聊天是一串消息,而 agent 不发消息。它发出的是一条事件流 —— 推理、一次工具调用、工具的输出、一份文件 diff、一份任务清单、这份清单的修订 —— 其中只有偶尔某一条是真正说给你听的。
把这条流压平成聊天气泡,得到的是一个技术上正确、实际不可用的东西。
事件不是消息,而且在三个要紧的方面不是
它们有类型,显示需求各不相同。 一条 shell 命令和它的输出需要等宽字体、一个滚动区域和退出码。一次文件编辑需要带语法高亮的 diff 视图。一份任务清单需要就地更新的复选框。把这一切都当散文渲染,等于扔掉了所有让内容可读的可供性。
它们会变。 任务清单会随着工作推进被修订。一次工具调用先是待处理、然后运行中、然后带着结果完成。而在聊天里,消息一旦发出就不可变 —— 所以一条按聊天渲染的 agent 流,要么每次变化都追加一份新的清单副本、把当前状态埋在它自己的历史底下,要么静默更新、丢掉进展感。
其中大多数不是给你看的。 内部步骤与"人需要读的东西"之间的比例很高。把所有东西都以完整分量展示,会让真正的答案无处可寻;把所有东西都藏起来,agent 就成了一个看起来卡死的黑盒。正确答案两者都不是 —— 而是默认摘要、按需展开,并且摘要要携带足够的信息,让人能判断要不要展开。
管用的做法是给每种事件类型一张自己的紧凑卡片:终端卡片、diff 卡片、就地更新的任务清单卡片。每张折叠成一行,带上那个关键事实 —— 命令是什么、文件和改动行数、还剩几个任务 —— 并且都可展开。于是这段对话读起来像一份工作日志而不是一份聊天记录 —— 而它本来就是工作日志。
难的部分是延迟,不是布局
编码 agent 在两次可见输出之间会思考很久。在手机上,十秒钟什么都没有,和崩溃是无法区分的,而用户的应对方式是把 app 切到后台 —— 那恰好是连接最不该断的时刻。
三件比任何视觉决策都更重要的事:
立刻流出点什么。 哪怕只是一个"请求已收到"的确认,配一个可见的状态变化。从"已发送"到"模型开始产出"之间的那段空隙,正是用户流失发生的地方。
显示当前活动,而不是转圈图标。 "正在读取 src/auth/session.ts"比一个不确定进度条好无数倍,因为它是进展的证据,而不是关于进展的断言。agent 本来就在发出这些信息;要做的是把它们呈现出来,而不是缓冲到某件事"完成"为止。
把中断做成一等操作,而且要即时。 用户需要叫停一个走错方向的 agent,而这个停止必须感觉是立即生效的 —— 哪怕后端还要花点时间收尾。先在本地确认,再去对账。
我们一开始搞错的那件事
我们最初把 agent 的文本输出当纯文本渲染,结果满屏都是用户得绕着读的 markdown 语法 —— 字面的星号、井号、代码围栏。渲染 markdown 修好了症状。
更深的错误是:我们一开始就假设那些输出是散文。一旦你接受"这条流是结构化事件而不是一场对话",一系列决定就变容易了:该持久化什么、默认该显示什么、历史里的"一条消息"究竟指什么、往回滚意味着什么。我们围绕事件重建,不再和聊天这个隐喻较劲,剩下的大部分问题就自行消解了。
可推广的那个断言
当一种新的交互模式出现时,为它做的第一个界面总是从最近的熟悉事物那里借来的。这是正确的起步方式 —— 但借来的隐喻里烘着一堆假设,而一旦你的内容不再满足这些假设,这个隐喻提供的价值就开始不如它的成本。
聊天假设消息是离散的、不可变的、有具体收信人的、重要性大致相当的。agent 的事件流四条全违反。认出正在被违反的是哪一条假设,就是指向重新设计的那个指针 —— 而对我们来说,那次重新设计不是做一个更好看的聊天,而是承认我们做的根本不是聊天。