流式輸出怎麼在終端兩區之間定稿
模型按 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。
進階一問:如果這段文字其實是表格的半截行,扣留打開和關掉時,用戶分別會看見什麼。