Skip to content

Agent 流式输出时的终端光标闪烁:一次 TUI 渲染修复

TUI, IME

问题

在 Agent 流式输出过程中,模型输出会持续到达,界面随之刷新。此时输入框里的终端光标会出现异常闪烁:光标有时跳到旧位置,甚至会在输入框右下角留下短暂的残影。

这个问题只在 Agent 工作、动画边框持续变化时特别明显。普通输入或静态页面看起来基本正常,因此最初很容易把它误判成输入法或终端模拟器的问题。

渲染流程中的竞态

旧的渲染流程把一帧拆成了多次终端写入:

  1. 获取 Frame 并绘制 UI。
  2. 调用 Terminal::flush,把屏幕差异写入终端。
  3. 交换前后缓冲区。
  4. 单独设置硬件光标位置。
  5. 再根据状态调用 show_cursorhide_cursor
  6. 最后刷新 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_positionTerminal 的直接光标 API;
  • 是否在动画刷新期间重复切换光标显隐;
  • 是否把 UI 光标状态复制到应用层后又由终端库维护一份状态。