OpenAI Codex · 终端界面

流式输出怎么在终端两区之间定稿

模型按 token 往外推,终端却是一个写出去就改不了的字符网格。已经不会变的行交给 scrollback,还可能变的尾巴留在活动 cell,表格没闭合之前整段扣住。

课程目标读完能说清两件事:哪些行一旦写进终端 scrollback 就不可改;以及一张还没写完的 markdown 表,为什么必须扣在活动区,等到流结束才定稿。
先玩一遍 · 哪些行已经锁死,表为什么还在抖
同一段带表的短文,看它怎么在两区之间定稿
表格扣留
关掉后,表头按窄列先锁死,后面的长单元格改不了它。
假终端 · 稳定区在上,尾巴在下 已定稿 0 尾巴 0 扫描 None
稳定区提交即固化
活动尾巴可变
等待开始。
输入框停在原处。
逻辑轨迹 · 动画每一步对应源码里的哪一段
  1. 没有换行的 delta 不改可见尾巴streaming.rs L489
  2. 收集器等到换行才提交 sourcemarkdown_stream.rs L87
  3. 扫描器看上一行是不是表头table_holdback.rs L23
  4. 确认表之后从 header 起整段扣在尾巴controller.rs L384
  5. 散文行入队,等 tick 写进 scrollbackcontroller.rs L343
  6. tick 把稳定行写成 HistoryCellstreaming.rs L399
  7. insert_history 写进终端 scrollbackinsert_history.rs L3
  8. 流结束才把整张表一次定稿controller.rs L160
点播放,看一段带表的短文怎么在稳定区和活动尾巴之间定稿。
锁住的行散文进稳定区之后,列宽再变也碰不到它。
扣住的表扣留打开时整张表在尾巴里一起重排。关掉后,表头按窄列锁死,长单元格只能另排。
什么时候定稿流还在走,表就不能进 scrollback。finalize 之后整段一次提交。
教学示意:短文与列宽为课程化设定。轨迹行号对应 openai/codex commit 4f39251a01。
思路一 · 已经不会变的行,交给终端自己保管
它解决什么问题

你盯着终端看模型写答案。散文还好,一行一行往下长。接着它开始吐一张表:先出表头,再出分隔行,再出第一行数据。列宽每来一行就变一次。刚才对齐好的 Description 被挤到下一列,上一帧的竖线还印在屏幕上。

你往上滚想看刚才那句结论,滚轮动了,历史和正在写的尾巴叠在一起。网页换 DOM 时浏览器会保住滚动位置。终端往 stdout 写一个字,光标就往前走一格。想留住旧答案,又想让表跟着新行改列宽,就得先决定哪些格子属于过去,哪些还属于现在。

思路是什么

Codex 把渲染结果切成两区。能确定不再变的行进动画队列,等 commit tick 写成 HistoryCell,用转义序列塞进终端自己的 scrollback。还可能变的行只活在活动 cell 里,下一帧可以整段换掉。

出处:codex-rs/tui/src/streaming/controller.rs 第 1 至 36 行

控制器同时记两套长度。enqueued_stable_len 是交给队列的行数,emitted_stable_len 是写进 scrollback 的行数。尾巴从 enqueue 边界算起。按 emit 切的话,排队还没写出的行会在活动 cell 里再出现一次。三个指针同向移动,已经 emit 的那一截不许回头改。

没换行的 token 连尾巴都不更新。用户看见的最小时间单位是一行 markdown source。半行表格如果先画出来,下一秒结构一对,列会立刻消失再长出来。未结束的 source 进缓冲,不能改可见尾巴。

出处:codex-rs/tui/src/chatwidget/streaming.rs 第 489 至 492 行

模型 delta 按 token 到达 换行门 半行留在缓冲 两区划分 稳定行 / 可变尾 稳定区 tick 后写进终端 scrollback 活动尾巴 下一帧可以整段替换 已经 emit 的行不会被收回,尾巴从入队边界算起
教学化结构图:同一条流,先过换行门,再拆成不可改的历史和还可改的尾巴。
为什么长期成立

这是格子所有权的合同。已经交给终端 scrollback 的字,进程里没有可写副本。用户用滚轮、搜索、复制,用的是终端自己的能力,TUI 不必再做一份完整历史视口。代价是提交之后不能改。换个语言重写,只要界面是终端字符网格,这道题还在。

思路二 · 表格没闭合,整段扣在尾巴里
它解决什么问题

markdown 表加一行就能改所有列宽。表头如果已经按窄列冻进 scrollback,后面的长单元格没法回去改它。竖线对不齐,残影留在原处。

普通散文里的竖线也会误伤。一句 status | owner | note 看起来像表头,其实只是一句话。扣得太狠,这段散文会被卡住,直到流结束才放行。

思路是什么

扫描器只认 header 加 delimiter。上一行像表头、下一行还没到,先乐观扣住,状态叫 PendingHeader。后面来的如果是普通散文,状态回到 None,那一行再进稳定队列。两行对上了,进入 Confirmed,从 header 起整张表留在尾巴,直到 finalize。表前面的散文可以继续提交。

出处:codex-rs/tui/src/streaming/table_holdback.rs 第 21 至 32 行

尾巴预算由扫描状态决定。None 时预算是 0,行直接进稳定队列。PendingHeader 和 Confirmed 则从 header 起点起整段扣住。Raw 模式预算也是 0,表格当纯文本流走,列宽问题交给用户自己的终端选区。

出处:codex-rs/tui/src/streaming/controller.rs 第 384 至 412 行

sh 围栏里的竖线当代码,不触发扣留。连续多张表时,第一张没结束,后面的表也不提前定稿,超长多表答案会在活动区堆到流结束。

None 行直接进稳定队列 像表头 PendingHeader 先扣住,等下一行 分隔行 Confirmed 从表头起整段留在尾巴 来的是散文 回到 None,放行 finalize 后一次定稿 只认 header 加 delimiter,普通竖线散文不会被扣到流结束
教学化状态图:乐观扣留只多停一会儿,确认之后整张表都等流结束。
为什么长期成立

任何按增量画表的界面都有这个问题。列宽是全局量,局部追加会改已经画过的行。把整张未闭合的表留在可变区,是这条约束的最小解。网页里对应的做法是,表格节点在闭合之前不要拆进不可变的 DOM 片段。

提交即固化。没闭合的表,先扣住。
思路三 · 队列用两档速度,一帧里的写入要一起走
它解决什么问题

稳定行入队之后,立刻全部写进终端也不对。慢流希望一行一行长出来,像打字。快流希望队列赶得上模型。只有一档速度,两端都会难受。

还有一帧里的两处写入。历史行用转义序列写到 viewport 上方,ratatui 再画输入框和活动尾巴。拆成两次刷新,用户会先看见历史往上跳一截,输入框还停在旧位置。

思路是什么

排水分两档。平滑档每个 tick 出一行,追赶档一次抽空当前队列。深度到 8 行,或者最老一行超过 120 毫秒,就能进追赶。退出要深度降到 2、年龄降到 40 毫秒,并且保持 250 毫秒。退出后再挡 250 毫秒,除非堆到 64 行或 300 毫秒。策略不看这段文本是标题还是表格,只看队列深度和年龄。

出处:codex-rs/tui/src/streaming/chunking.rs 第 82 至 125 行

定时线程只按帧间隔发 CommitTick,抽多少行是策略的事。这个间隔等于 120 FPS 的下限,平滑档上限就是每秒 120 行。写屏时,scrollback 插入和 viewport 绘制包进同一次 sync_update。双 buffer 只把变过的格子写出去。画回调必须画满整帧,少画一块,终端会留下上一帧的残字。

出处:codex-rs/tui/src/tui.rs 第 954 至 973 行

稳定队列 按到达时间排队 Smooth 每个 tick 一行 CatchUp 一次抽空当前队列 一次 sync_update 历史插入和 viewport 绘制一起刷新 进入门槛高于退出门槛,避免在 8 行附近来回打齿
教学化时序图:抽多少行由队列压力决定,写出去的两处动作必须包在同一帧。
为什么长期成立

两档齿轮对付的是同一种队列的两种压力。深度抓一下子来了很多行,年龄抓行不多但等太久。一帧里的多处写入要原子提交,终端用同步输出协议,网页用一次 DOM 替换。先清屏再画,中间帧一定会被人眼看见。

横向对比 · 历史放在哪,决定哪几层是必需的

Claude Code:整棵消息树都能改,同步输出先问终端

Claude Code 的 TUI 是 Ink。每帧先调和 React 树,再对前后屏做 cell diff,最后决定要不要用 DEC 2026 的 BSU/ESU 包一层。探测函数写得很直:支持时能避免重绘闪烁;tmux 会拆包,原子性已经没了,再发这 16 个字节只是给外层终端添负担,所以直接跳过。

它没有稳定区、可变尾、表格 holdback 这套。已经画出来的节点,下一帧还能改。代价是调和发生在 JS 里。Codex 把已提交行交给终端 scrollback,活动区本来就小,所以每次 draw 都走 sync_update,不再维护一份终端白名单。

已核对 restored-src/src/ink/terminal.ts 第 66 至 74 行 · 2026-08-22

Grok Build:历史留在进程里,每个 chunk 作废缓存

Grok 的 pager 也是 ratatui TUI,历史却不交给终端 scrollback。Agent 块按 EntryId 活在进程里的 ScrollbackState。来一个 chunk,就往同一块追加文本,立刻 invalidate_cache,并标记高度脏。下一次布局按新宽度重算。

宽度一变,进程里的块能按 source 重画。Codex 已经 emit 的行属于终端,只能等 finalize 之后用完整 source 再生成一份可重绘的 cell。Grok 付的账是内存里挂着全部块,每个 chunk 都作废布局缓存。

已核对 xai-grok-pager/src/scrollback/state/mod.rs 第 915 至 926 行 · 2026-08-22
课堂练习
01

超长段落一直不换行,屏幕上会停在哪

模型在一个超长段落中间一直不吐 \n。对照 push_delta 只在看到换行才提交渲染,以及「Unterminated source is buffered by the controller and cannot change the visible tail」这行注释,写出:可见尾巴何时更新,这段文字何时进入 scrollback。

进阶一问:如果这段文字其实是表格的半截行,扣留打开和关掉时,用户分别会看见什么。

Takeaway:没换行的 token 不可见。能确定不再变的行进终端 scrollback,提交即固化。还可能变的行,尤其是没闭合的表,只活在活动尾巴里,等流结束再一次定稿。