流式输出怎么在终端两区之间定稿
模型按 token 往外推,终端却是一个写出去就改不了的字符网格。已经不会变的行交给 scrollback,还可能变的尾巴留在活动 cell,表格没闭合之前整段扣住。
- 没有换行的 delta 不改可见尾巴streaming.rs L489
- 收集器等到换行才提交 sourcemarkdown_stream.rs L87
- 扫描器看上一行是不是表头table_holdback.rs L23
- 确认表之后从 header 起整段扣在尾巴controller.rs L384
- 散文行入队,等 tick 写进 scrollbackcontroller.rs L343
- tick 把稳定行写成 HistoryCellstreaming.rs L399
- insert_history 写进终端 scrollbackinsert_history.rs L3
- 流结束才把整张表一次定稿controller.rs L160
你盯着终端看模型写答案。散文还好,一行一行往下长。接着它开始吐一张表:先出表头,再出分隔行,再出第一行数据。列宽每来一行就变一次。刚才对齐好的 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 行
这是格子所有权的合同。已经交给终端 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 围栏里的竖线当代码,不触发扣留。连续多张表时,第一张没结束,后面的表也不提前定稿,超长多表答案会在活动区堆到流结束。
任何按增量画表的界面都有这个问题。列宽是全局量,局部追加会改已经画过的行。把整张未闭合的表留在可变区,是这条约束的最小解。网页里对应的做法是,表格节点在闭合之前不要拆进不可变的 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 行
两档齿轮对付的是同一种队列的两种压力。深度抓一下子来了很多行,年龄抓行不多但等太久。一帧里的多处写入要原子提交,终端用同步输出协议,网页用一次 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
超长段落一直不换行,屏幕上会停在哪
模型在一个超长段落中间一直不吐 \n。对照 push_delta 只在看到换行才提交渲染,以及「Unterminated source is buffered by the controller and cannot change the visible tail」这行注释,写出:可见尾巴何时更新,这段文字何时进入 scrollback。
进阶一问:如果这段文字其实是表格的半截行,扣留打开和关掉时,用户分别会看见什么。