Agent 流式输出时的终端光标闪烁:一次 TUI 渲染修复
TUI, IME
问题
在 Agent 流式输出过程中,模型输出会持续到达,界面随之刷新。此时输入框里的终端光标会出现异常闪烁:光标有时跳到旧位置,甚至会在输入框右下角留下短暂的残影。
这个问题只在 Agent 工作、动画边框持续变化时特别明显。普通输入或静态页面看起来基本正常,因此最初很容易把它误判成输入法或终端模拟器的问题。
渲染流程中的竞态
旧的渲染流程把一帧拆成了多次终端写入:
- 获取
Frame并绘制 UI。 - 调用
Terminal::flush,把屏幕差异写入终端。 - 交换前后缓冲区。
- 单独设置硬件光标位置。
- 再根据状态调用
show_cursor或hide_cursor。 - 最后刷新 backend。
问题在于,屏幕内容和光标状态并不是一个不可分割的提交。Agent 每次收到增量文本后都会触发下一帧,终端可能在两次写入之间暴露中间状态。尤其是光标位置变化、显隐切换和动画边框同时发生时,输入法组合文本或终端自身的光标绘制可能短暂落在旧位置。
这不是业务状态错乱,而是渲染提交边界不一致。
修复思路
Ratatui 的高层 Terminal::draw API 允许在绘制闭包中设置当前帧的光标位置,让渲染库统一完成以下工作:
- 计算并刷新屏幕差异;
- 根据当前帧决定显示或隐藏光标;
- 设置光标位置;
- 交换缓冲区并刷新 backend。
关键代码可以简化为:
terminal.draw(|frame| {
if let Some(position) = ui::draw(frame, app) {
frame.set_cursor_position(position);
}
})?;
ui::draw 只负责返回光标应该出现的位置,不再直接操作终端。这样光标就成为当前帧的一部分,而不是绘制结束后追加的一组独立命令;光标显隐也由帧状态统一管理,不需要应用层再额外维护一份容易失同步的标志。
经验
终端 UI 中,光标看似只是一个很小的附属状态,实际上和屏幕缓冲区、输入法组合文本、终端刷新节奏紧密相关。只要界面存在高频刷新,就应该尽量让内容、光标位置和光标显隐在同一个渲染提交中完成。
遇到类似问题时,可以优先检查:
- 是否在一帧绘制后又单独写终端控制序列;
- 是否同时使用了
Frame::set_cursor_position和Terminal的直接光标 API; - 是否在动画刷新期间重复切换光标显隐;
- 是否把 UI 光标状态复制到应用层后又由终端库维护一份状态。