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,提交即固化。還可能變的行,尤其是沒閉合的表,只活在活動尾巴裡,等流結束再一次定稿。