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

一行状态文字的状态机

系列第二十三篇,按阅读顺序插在第 12 篇之后。前面各篇讲的都是“大“问题:渲染所有权、双模、事件树展平。这一篇讲一个“小“东西——状态行,就是那行“正在工作“的提示:一个 spinner、一行 shimmer 文字、一句“Working (0s · esc to interrupt)“。每个人都写过 spinner,没人写过“什么时候显示什么、什么时候必须消失”。2026 年 10 月我逐行读完 Codex 的状态指示代码,发现这行文字背后是我在这个系列里见过的最密集的微观决策集合——它的难点不在动画,在防闪烁。


一、状态行的两种所有权

先把我自己的坐标摆出来。第 4 篇讲过 ForgeLoopTUI 的 operationStart/operationEnd:工具开始时追加一行“⎿ running…“,工具结束时用结果行替换它。状态行的所有权属于那个工具——它因事件而生、因事件而灭,生命周期和一次工具调用对齐。

Codex 的状态指示器是另一个物种。它的所有权属于整个 turn:从回车到 turn 收敛,不管中间经过几次工具调用、几段流式输出,底部都是同一个指示器实例,显示的内容在变(“Thinking”/“Working“加一行细节),实例不变。它从不被“结果“替换,只会被流式内容压制,然后再被恢复。

这个区别决定了后面一切。被替换的状态行不需要状态机——它的生灭就是事件本身;被压制和恢复的指示器需要——压制和恢复的时机全是边界条件。


二、reasoning 不进 transcript

第一个反直觉的设计:reasoning(模型的推理流)不进历史区。

Codex 的 ChatWidget 收到 reasoning delta 时只往一个 buffer 里累积,然后从 buffer 里提取第一个 **加粗** 片段,作为状态行的标题显示。模型在推理块里写的“我先分析用户的需求“这类句子,只有被加粗的那一句能登上状态行;整个推理过程在 transcript 里不留痕迹(推理块结束时只落一条摘要记录)。

配套的降级同样是静默的:如果 buffer 里还没有出现加粗片段,就保持现有标题不动——不显示“正在提取标题“之类的中间态。还有一条优先级细则:如果正在等一个 unified exec 的结果,执行等待的状态优先于推理标题,后者直接让位。

这套“从内容里蒸馏状态“的做法,把状态行从“系统播报“变成了“内容摘要“。它还顺带决定了终端窗口标题:Thinking 和 Working 两种状态会映射到窗口标题上,用户在 Dock 或窗口列表里就能分辨 agent 是在想还是在动手。状态行的信息层次因此是:窗口标题 < 状态行标题 < 状态行细节,同一状态的三档投影。


三、防闪烁三闸门

现在到了正题。状态行和流式内容共享同一块屏幕底部区域,两者共存会闪——一帧里既有新放行的内容又有“正在工作“,视觉上就是抽搐。Codex 的解法是三道理性得近乎偏执的闸门。

闸门一:放行即隐藏。 commit tick(第 7 篇补记讲的两档变速箱)每放行一批行进历史区,第一件事就是隐藏状态指示器。流式内容与状态行互斥,没有例外路径。

闸门二:恢复看空闲。 一段 commentary(前言性输出)结束时,不立刻把状态行放回来。先置一个“待恢复“标记,然后等三个条件同时成立:turn 还在运行、两个流式队列(回答流与计划流)全部 idle、标记置位——才恢复。缺任何一个条件就继续压着。没有这个闸门,“前言结束→工具即将开始“的间隙里状态行会闪现一下又被压掉,形成“显示→隐藏→显示“的三连闪。

闸门三:收尾看队列。 最终回答完成时,状态行是否恢复,取决于输入队列里有没有用户插进来的 steer 消息。如果用户已经插了话,turn 马上又要忙起来,恢复那一帧就是多余的闪烁——不恢复了,直接保持隐藏等下一波。

三道闸门串起来的判断标准是同一个:状态行的每一次显隐都必须对应用户心智模型里的一次真实状态切换,任何“技术上正确但感知上是噪声“的显隐都要被闸门吃掉。


四、中断偏序:另一条隐藏的状态机

还有一个相邻机制值得在这里记一笔。流式写入进行中,ExecBegin/ExecEnd 这类生命周期事件不允许立刻上屏——它们先进一个 FIFO 队列延后,等流式写入的空窗再处理(defer_or_handle)。

理由和防闪烁同源:状态区与流内容如果交错落笔,两者的时间线都会乱。生命周期事件是状态,流式内容是正文,正文正在流动时,状态必须排队。这等于承认了一个调度原则:底部状态区是单写的,两个写者之间用偏序关系串行化,而不是靠运气。


五、动画是自驱动的

最后一块拼图是帧从哪来。spinner 不需要等内容事件,它在自己的渲染里通过 schedule_frame_in 主动预约下一帧(有动画时约 32ms 后,只有计时器时 1 秒后);shimmer 那道扫过文字的渐变高光同理,是按已流逝时间计算的纯函数。第 7 篇补记讲的全局帧调度只管内容放行,状态行动画自请帧,两条帧请求线在同一个渲染循环里合流。

这也是状态行与第 12 篇提过的 spinner 细节的对照点:Kimi 的差分渲染器把“只有一行变化(例如 spinner 动画)“列为最小化重绘的显式动机。状态行是 TUI 里唯一合法的高频自更新区域,所有为它服务的机制——自请帧、单行重绘、闸门防闪——本质上都在回答同一个问题:怎样让唯一会动的东西安分地动。


六、收束

写 spinner 只要半小时,写状态行的状态机要付出的代价全在边界上:放行与恢复的互斥、显隐的时机、多写者的偏序、动画的帧来源。这行文字的复杂度不在它显示什么,在于它什么时候不该显示。

这也是我读完 Codex 这部分源码后修正的一个直觉。我原以为状态指示是“锦上添花“层,属于打磨;看完三闸门才意识到它是 turn 生命周期在用户侧的投影——turn 的每个相位迁移(推理、输出、工具、等待输入)都必须在这行文字上有唯一确定的显示状态,不多不少。一帧里出现两个状态,或一个状态出现两帧,都是 bug。 这个标准比一般 UI 组件苛刻得多,而它是 agent TUI 的日常。

下一篇讲测试:这样的状态机、连同前几篇讲的所有渲染机制,怎么被钉在回归测试里——TUI 的输出是带状态的字节流,断言它需要的不是一两个技巧,是一整套基建。


本文基于 Codex 本地源码 2026-10-04 的逐行阅读:codex-rs/tui/src/chatwidget/streaming.rs(reasoning 标题提取、防闪烁三闸门、defer_or_handle)、status_indicator_widget.rs(schedule_frame_in 自请帧)、shimmer.rs(渐变高光)、streaming/commit_tick.rs。对照部分基于 ForgeLoopTUI v1.2.0 的 operationStart/End 机制(见第 4 篇)。快照 main 6b9826e3(2026-09-09);行号可能随上游漂移,机制名稳定。