Article
Orca 聊久了开始卡,我把 TUI 的滚动重做了一遍
前言
这次改 Orca,起点是一个很具体的体验问题:会话短的时候一切正常,聊得久一点,TUI 往上滚就开始发涩。再长一些,触控板连续滚动时,画面甚至像停住了。
这种问题很容易被归到“终端性能就这样”。
但我用一个真实长会话做基线,在同一 session、同一 160x40 终端里连续注入 400 次滚轮事件,每次间隔 5ms。修正探针并隔离 update prompt 后,旧版重复三次,首个可确认滚动状态都要等 4.38–4.87 秒。这已经不是一点掉帧了,用户手指还在滚,屏幕却像没收到消息。
更麻烦的是,它只在长会话和持续输入叠在一起时明显。普通开发会话、单次 PageUp、短 transcript,甚至不少单元测试都看不出问题。于是它会悄悄积累,直到真拿 Orca 跑过一个长任务的人,才突然发现历史记录几乎滚不动。
我最后没有给滚轮回调加一个 debounce,也没有只调低刷新频率。
这次一共改了 17 个文件,新增约 2508 行,删除约 500 行。事件循环、消息变更协议、Markdown 渲染缓存、换行表示、视口选择和 PTY 验收,全都重新梳理了一遍。
代码已经在这个提交里:
9526c09b refactor(orca-tui): 实现 TUI 滚动性能优化全流程
聊得越久,每次滚动做的事越多
先把旧链路拆开,问题其实不难理解。
Orca 的 TUI 基于 Rust、crossterm 和 ratatui。旧实现每画一帧,都会把完整会话重新变成一个大的 Paragraph:
全部 messages
-> 重新构建 Vec<Line>
-> 重新解析每条 Assistant Markdown
-> 重新计算表格和样式
-> 重新做 word wrap
-> Paragraph.scroll(offset)
-> 终端 diff 输出
这里有几层成本叠在一起。build_lines_for_messages 每帧都会为所有消息重新分配 Vec<Line>,里面还带着字符串和 span;每条 Assistant 消息都要重新走一次 pulldown-cmark,滚动一格,历史里所有回复的 Markdown 又解析一遍。
原来的高度缓存也只缓存“有多少行”,不缓存已经构建好的行。它的命中判断还要对消息全文做哈希。tool output 一长,光确认“缓存能不能用”就要重新扫一遍大段内容。
最后,完整 transcript 还是交给 Paragraph::scroll。ratatui 需要从前面开始做 word wrap,再跳过 offset 之前的行。会话越长,位置越靠后,付出的工作越多。
还有一个独立问题藏在事件循环里。
旧主循环 poll(50ms) 后一次只读一个终端事件。鼠标事件处理完会直接 continue,键盘滚动也会从中间返回,而 terminal.draw() 和 runtime event projection 在这些分支后面。
触控板惯性滚动会连续产生大量 MouseEvent。只要事件还在到达,循环就可能一直处理输入,再一直跳过绘制。画面看起来卡住,随后又突然跳一截。
所以这次卡顿其实有两层:输入洪峰会让绘制饿死,长会话的全量重建又把每次绘制放大成 O(全部历史)。
total_lines 当时还是 u16。超过 65535 行以后,即使前面的性能问题都不存在,滚动范围也会先被截断。
两份分析,分歧在第一步
动手前,我让两个模型分别分析了同一份代码。
Claude Fable 5 的分析先抓到了全量重建这条主线:Markdown 每帧重解析、缓存 key 需要扫描全文、没有视口裁剪、Paragraph 仍然处理全部行,total_lines 还受 u16 限制。
完整分析截图:Claude Fable 5

这份分析的大部分判断都对。它把 scrollback flush 放在了最高优先级:让 flushed_count 往前推进,把稳定历史交给终端 scrollback,live 区只画尾部。
如果产品仍然采用旧的终端 scrollback 架构,这是一条很直接的路。
GPT-5.6-sol 给出的优先级不一样。它先把“滚动事件会跳过绘制”单独提了出来,认为事件循环的渲染饥饿是首要根因,长会话全量重建是放大器。它还沿着 Git 历史确认,native scrollback 曾经存在,但后来为了 fullscreen in-app scrolling 被移除了。
完整分析截图:GPT-5.6-sol

最后采用的是 GPT-5.6-sol 的顺序:先修事件循环,再做按消息缓存和真正的视口虚拟化,不恢复 native scrollback。
这段对比真正有用的地方,不是谁列的问题更多。两份分析对热点的判断高度一致,分歧出现在第一个架构动作上。性能优化一旦涉及产品行为,优先级不能只看理论收益,还要看它会不会改掉用户已经依赖的交互契约。
我没有把旧的 scrollback 机制接回来
Orca 代码里确实还保留着 flushed_count、flushable_prefix_end 这类状态。
只看当前调用关系,很容易得出一个结论:机制写好了,只是生产代码没有接线。
但顺着历史再看一层,会发现它属于已经移除的 inline viewport / native scrollback 方案。那个方案会把已经稳定的消息交给终端历史,只在 live pane 里渲染尾部。
它快,是因为较早的历史交给了终端,Orca 只负责 live pane。代价是完整历史不再处于 Orca 的统一应用内视口。
现在 Orca 是 fullscreen in-app scrolling。用户在应用里可以 PageUp、Ctrl+Home,也可以在流式输出时上翻历史,再回到底部恢复自动跟随。重新推进 flushed_count,等于把一部分历史移出 Orca 的视口管理,原来的完整导航能力会被拆开。
所以这次给自己定的约束很明确:
- 所有 retained messages 继续留在应用里;
- 滚动时不重新解析未变化的 Markdown;
- 在 warm-cache 的纯滚动帧里,视口外内容不再被物化成 ratatui
Line; - 超长会话不能在 65535 行附近失真。
flushed_count 继续作为兼容状态保留,但性能不能依赖它前进。
先保证输入再多,画面也有机会刷新
我先改的是事件循环。
新加的 FrameScheduler 很小。它不负责渲染内容,只管理 dirty 状态、帧截止时间和动画截止时间。
frame interval: 16ms
animation interval: 80ms
input batch limit: 64
runtime batch limit: 256
每轮最多处理 64 个输入事件、256 个 runtime events。相邻同方向滚轮会合并成一个有符号 delta;方向一旦切换,就立即结算前一段,避免合并以后在列表顶部或底部得到错误结果。
普通输入路径不再绕过 draw decision。即使输入队列一直有东西,16ms 的帧截止时间到了,dirty frame 仍然有机会画出来。
这里处理的是公平性,不只是节流。模型流式输出、tool event 和用户滚动是三条同时发生的输入。如果只顾着快速吃完鼠标事件,runtime 更新会延迟;如果每个事件都立刻重画,终端又会被无意义的帧淹没。批处理和帧预算让它们在同一个循环里都有位置。
空闲时也不再固定 draw。普通状态没变,就继续等待。Running 状态保留 80ms animation tick,每次 tick 到达时标记 dirty,确保 spinner 按原来的节奏刷新。
我先用纯调度器测试把这个行为钉住:持续输入不能饿死 dirty frame;输入和 runtime 都必须在 draw decision 前得到服务;多个 dirty 通知合并成一次绘制;完全空闲时不画。
缓存的前提,是系统知道哪条消息变了
事件循环解决了“滚动时为什么不画”。下一步才是“画的时候为什么这么贵”。
一开始最容易想到的是给 Vec<Line> 加缓存。
但缓存不是放一个 HashMap 就结束。Orca 的消息会通过很多路径变化:流式 assistant delta、tool output 增量、subagent 状态、展开和收起、session resume、backtrack、clear、retain receiving progress。
如果系统不知道哪条消息变了,缓存只能继续哈希全文,或者保守地全部失效。两种做法都会把成本带回来。
这次我把显式 revision 放进了 AppState,并把消息写路径集中到一组 helper:
push_message
replace_messages
clear_messages
truncate_messages
replace_message
mutate_message
touch_message
retain_messages
每条消息修改时更新自己的 revision,只失效对应缓存项。width、theme、force-expand 这类全局布局条件变化时,才做更大范围的重建。
流式回复通常只 touch 最后一条消息。旧 tool 或 subagent 行展开时,也能准确找到那条历史消息,不需要假设“finalized message 永远不会变”。
Spinner 又是一个容易被忽略的点。
如果每个动画 tick 都为 spinner 重新渲染整条 tool output,大段静态内容就会被动画不断扫过。最终实现只 patch 缓存头部的 spinner 文本,输出正文保持不动。
确定性测试给了两个很清楚的结果:
- 第二个 scroll-only frame 构建 0 条消息,解析 0 条 Markdown;
- assistant delta 只重建最后一条消息。
这比“缓存命中率应该不错”更可靠,因为它直接约束滚动帧允许做多少工作。
只把看得见的行交给 Paragraph
缓存消息内容以后,如果最后仍然把完整历史拼回一个大 Paragraph,ratatui 还是要从头做布局。
接着要做视口虚拟化。
新的 TranscriptRenderCache 为每条消息保存视觉高度,再维护一份累计高度。给定当前 offset 和可见高度,可以用二分查找定位第一条和最后一条可见消息。
flowchart LR
A[Terminal input] --> C[Bounded frame scheduler]
B[Runtime events] --> C
C --> D[AppState message revisions]
D --> E[Per-message render cache]
E --> F[Cumulative visual heights]
F --> G[Visible rows only]
G --> H[ratatui Paragraph]
这里还有一个内存细节。
如果一条逻辑消息本身有 70000 个视觉行,提前把它展开成 70000 组 Line / Span / String,即使只画其中 30 行,分配成本仍然很高。
最终缓存采用更紧凑的表示:一份文本、一组 style runs、一组 row boundaries。只有进入当前视口的 row,才临时 materialize 成 ratatui Line。
这里的“只物化可见行”有一个前提。冷缓存或全局失效时,系统仍然需要构建 dirty 的逻辑消息;真正省下的是 warm-cache 的 scroll-only frame:离屏消息不再重建,最终 Paragraph 只接收当前可见行。
换行也不能只做一个近似高度估算。CJK 宽字符、NBSP、ZWSP、连续空白、样式 span、对齐方式,都可能让“缓存认为的行数”和 ratatui 真正画出来的行数分叉。
所以实现里有一份与 ratatui 0.29 WordWrapper 对齐的 wrapper,并用 Buffer 做差分测试。预换行完成后,最终 Paragraph 不再开 .wrap(),避免已经切好的 2050 个连续空格又被第二次换行。
全局的 scroll_offset、total_lines 和 visible_height 也都改成了 usize。测试覆盖了 70000 行偏移和 usize::MAX 附近的饱和运算。
到这里,滚动路径的成本才真正和“当前看见多少内容”挂钩,而不是和“这个 session 一共聊了多久”挂钩。
第一版能跑,但评审证明它还不够快
这次重构还有一段我不想省略。
第一版缓存和虚拟视口已经能工作,测试也能过,但独立评审继续挖出了几类隐藏成本:
- session completion 即使没有 receiving placeholder,也会让完整缓存失效;
- cache prepare 虽然多数条目命中,仍然每帧扫描所有消息;
- 70000 行的大消息仍会提前分配 70000 组行对象;
- spinner tick 还可能触发整条消息重建;
- compact spinner 文本有机会停在旧帧;
- 预换行内容交给
.wrap()后,会在长空白场景二次换行。
这些问题有一个共同点:功能上看不出错,但会让“大历史下的滚动成本”悄悄回来。
第二轮改造把失效索引换成 dirty BTreeSet。滚动命中缓存时,prepare() 连消息总数都不再遍历。retain 和 completion 尽量重映射旧缓存,而不是一把清空。
超高消息改用紧凑行边界,spinner 原地 patch,最终 Paragraph 关闭二次 wrap。
评审最后没有再发现具体正确性问题,但我还是把几个边界记了下来:
messages目前还是pub(crate)。如果未来有人绕过 helper 做同长度原地修改,revision 可能不会同步;- 自定义 wrapper 镜像了 ratatui 0.29 的内部行为。升级 ratatui 时,必须继续跑差分测试;
- 缓存内存和 retained history 仍然是线性关系,只是不会在每个滚动帧重复制造整份行对象。
它们不是当前卡顿的残留根因,但这种东西如果不写清楚,下一次升级 ratatui 或改消息写路径时,很容易又被带回来。
测性能时,测试工具也会骗人
写完实现以后,我没有只跑单元测试。
我用 PTY 启动真实 target/debug/orca,恢复一个约 1.4MB、包含数百条消息的长 session,再向终端持续写入 SGR mouse wheel 序列。
第一版探针却告诉我:TIMEOUT。
继续查以后发现,ratatui 的终端 diff 会把 scroll: 文本和光标控制序列拆开。探针直接在原始 ANSI 字节里跑正则,屏幕明明已经变了,正则却永远看不到连续字符串。
于是我给探针加了一个最小 ANSI screen model,真正维护光标和屏幕 cell,再从屏幕状态判断滚动。
流式 API 探针又暴露了第二个问题。
它持续向 PTY 做阻塞式 os.write。输入回压以后,Python 进程会卡在 write 系统调用里,超时分支根本执行不到。我最后用 macOS sample 抓到主线程 100% 停在 os_write -> write,才把它改成非阻塞写,并单独统计 dropped events。
还有一次数字是 5977.8ms。
这个值也不能直接当滚动延迟。它量到的是模型输出从零增长到超过 35 行、第一次具备可滚动空间的时间。计时基准改成“产生首个滚动帧前最近一次滚轮输入”以后,才得到真正的 input-to-frame 观察值。
这段排查让我重新确认了一条很朴素的原则:性能探针本身也需要验证。测量工具如果不理解 ANSI、PTY 回压和流式内容增长,它会把自己的错误包装成产品结论。
拿真实长会话和真实 API 验一遍
修正探针后,我把旧版 parent commit 76b742a1 和新版 9526c09b 放回同一个实验:同一 session、400 次滚轮、5ms 间隔,用同一个 ANSI screen-model transcript cell 快照探针判定滚动,并把 update prompt 隔离在计时窗口之外。
160x40 下,每个版本重复三次;120x35 和 80x24 继续用同一口径做跨尺寸检查。
| 终端尺寸 | 旧版 76b742a1 首个可确认滚动状态 | 新版 9526c09b 首个可确认滚动状态 |
|---|---|---|
| 160x40 | 4.38–4.87s,三次中位数 4378.7ms | 5.3–12.8ms,三次中位数 5.8ms |
| 120x35 | 4485.6ms | 6.4ms |
| 80x24 | 1467.1ms | 6.4ms |
160x40 的旧版和新版三次重复都得到 page_up_scroll=YES 和 clean_exit=YES。这组结果替代了早期基于原始 ANSI 正则得出的 TIMEOUT:旧版不是七秒内完全不画,而是首个可确认滚动状态要等一到四秒多;新版在三个尺寸下都稳定在毫秒级。
然后我用真实 DeepSeek API 启动 TUI,让 deepseek-v4-flash 流式生成 120 行 Markdown,同时持续注入滚轮事件。
真实 API 场景的结果是:
scroll_first_frame=3.5ms
sent_up=758
dropped_up=0
stream_sentinel=YES
page_up_scroll=YES
home_top=YES
end_bottom=YES
clean_exit=YES
在这一次 120x35 的真实 API PTY 观察里,从最近一次滚轮输入到 ANSI 屏幕模型确认 transcript 已经滚动,用了 3.5ms。它与前面的离线长会话 A/B 不是同一场景,也不是一份跨机器通用 benchmark,但足以回答这次最关心的问题:模型还在流式输出时,持续滚动会不会再次把画面饿死。
答案是没有。PageDown 在流程中被执行;明确的状态断言覆盖了 PageUp、Ctrl+Home、Ctrl+End,以及回到底部恢复 auto-follow。终端动态缩到 83x24,再放大到 140x42,重排后仍能滚动并正常退出。
自动化验证也一起跑完:cargo test -p orca-tui 是 347 passed;串行 workspace test、cargo check -p orca-tui、cargo fmt --all -- --check、git diff --check 都通过。并行 workspace test 中间有一条与 TUI 路径无关、对时序敏感的 verifier 用例失败,隔离重跑通过;最后用串行 workspace 把整套验证收口。这里没有把偶发红灯藏掉,也没有仅凭这次并行失败就把它归为 TUI 回归。
改掉的其实是成本模型
回头看,这次滚动优化没有哪个单点特别神奇。帧调度器解决输入公平性,message revision 让缓存知道该信任什么,紧凑换行表示控制大消息的分配,累计高度和二分查找把完整历史与当前视口分开,PTY 探针把“感觉顺了”变成可重复的输入和数据。
它们合在一起,改掉的是 TUI 的成本模型:旧实现每一帧都为完整历史付费;新实现的 warm-cache 纯滚动帧,不再重建离屏消息,最终只把当前可见行交给 Paragraph。冷缓存或全局失效时,dirty 的逻辑消息仍然需要构建。
这次改完以后,我对 TUI 性能的理解变得更具体了。
它不只是“画得快不快”,而是当历史已经很长、内容没有变化、用户只是往上滚一下时,系统能不能忍住不重新处理整段会话。
这也是 Agent TUI 和普通终端小工具不一样的地方。Agent 会话天然会变长,里面有 Markdown、工具输出、diff、subagent 状态,还有流式更新。短会话里看不出来的 O(N),跑久了就会变成用户手上的停顿。
所以这次真正改掉的不是某个滚轮回调,而是成本模型。旧实现每一帧都在为完整历史付费。新实现只在需要的时候处理变化,只把当前看得见的内容交给终端。
这件事不花哨,但它决定了 Orca 聊久以后,用户还能不能顺畅地回头看发生了什么。
Keep Reading