第十章 终端界面如何显示流式回答
10.1 从一段新文字到一组屏幕行
终端并不是只会向下打印日志的窗口。程序可以发送控制序列,让光标移动、清除某一行或改变颜色。TUI 是 Terminal User Interface 的缩写,即终端用户界面。Pi 的 packages/tui 用这些基础能力显示消息、输入框、菜单和工具进度;它本身不负责调用模型,也不决定工具是否执行。
想象回答从“读取”变成“读取配置”,又变成“读取配置后发现……”。如果每次都把全部历史再次打印到末尾,用户会看到重复记录。如果每次清空整个窗口,又容易闪烁和丢失阅读位置。界面因此需要保存“当前应该显示什么”,把它排成行,再决定屏幕上哪些位置需要更新。
组件是可以组合的界面部件,例如一段文字、编辑器或菜单。基础约定是 render(width) 返回字符串数组:数组的一个元素代表一行,参数 width 是允许使用的终端列数。组件负责换行或截断,每行不能越过宽度。它把“我要显示这些行”交给渲染器,渲染器才负责写入终端。
以 Text 组件为例,它先计算左右留白,再给正文分配宽度,用保留颜色控制码的工具换行,最后补齐需要的空格。宽度和文字都没有变化时,可以复用缓存。setText 或 invalidate 会使相关缓存失效。缓存减少重复排版,但“需要重新排版”与“需要把所有行重写到屏幕”仍是两件事。
上层接到 Agent 的消息更新事件后,可以更新保存的文本并请求渲染。组件下一次根据当前宽度返回新行。模型增量、应用消息和屏幕行属于三个层次:增量是新来的片段,消息是累计的内容,屏幕行是特定窗口宽度下的排版结果。窗口变窄时,消息可以完全不变,屏幕行却需要重新计算。
10.2 比较前后画面,而不是反复清屏
差异渲染就是比较上一帧和当前帧,只更新需要变化的位置。帧指某一时刻准备显示的完整画面,并非模型的一次回答。主屏渲染器保存上次的行,找到首次与最后一次变化的位置,再通过光标移动、清行和输出新文字修补变化范围。若只有进度提示变化,通常没有必要重新写整段历史。
举个小例子:旧画面有“用户问题”“正在读取”“输入框”三行,新画面只把第二行换成“读取完成”。在布局稳定的情况下,程序可以移动到第二行,清掉旧字,再写新字。若不清行,只覆盖前几个字符,旧内容比新内容长时就可能留下尾巴。渲染器还需要追踪光标真正所在的位置,避免下一次相对移动落错行。
差异更新也有适用范围。终端宽度变化会改变换行,主屏当前视口之上的内容变化又无法直接原位修补,这些情况会触发完整重绘。代码还处理高度变化、内容缩短和图片占位等分支。完整重绘可能清除屏幕及滚动历史,所以“使用主屏”不能理解成任何情况下都绝不清理历史。
两种渲染器都用同步输出控制序列包住一次更新。在支持这一约定的终端里,可以把若干光标操作与写入作为一组显示,减少中间状态被看到的机会。它类似先准备一张完成的画面再展示。实际效果仍依赖终端支持,不能把协议能力当作每个环境里的绝对视觉保证。
渲染请求可以合并安排,避免每来一小段就重新处理画面。网络分块与屏幕刷新不必一一对应,模型调用和界面仍各自工作。
10.3 主屏与备用屏由谁管理滚动
主屏是平常命令行使用的终端缓冲区。TuiMainScreen 在这里显示内容,日常滚动主要由终端模拟器管理。输出长于窗口时,旧行进入终端的滚动历史。它适合把助手看作持续展开的对话文档,并与已有命令行工作习惯连接。组件只需描述内容,不能据此假定自己拥有一个固定高度的独立滚动区域。
备用屏是终端提供的另一套屏幕缓冲区,常见于全屏编辑器。TuiAltScreen 切换进去后,程序拥有一个与终端高度相同的视口,自己管理滚动、选择与搜索。它按屏幕行比较变化;通过 VStack、HStack 和 ScrollView,可以把窗口分成固定区域与可滚动区域,例如让输入框留在底部,只有消息区滚动。
这也解释了两种模式的行为差异。备用屏中用户向上查看历史时,新内容增长不会总把他拖回底部;回到末尾后可以继续跟随输出。鼠标事件由程序识别并分派。主屏则把鼠标滚动交给终端。它们不是同一种实现换了颜色,而是在“谁拥有滚动状态”上作了不同选择。
通常停止备用屏渲染时,程序恢复主屏并打印最终完整文档;使用保留屏幕的停止选项时,会采取不同恢复路径。这里说的是 TUI 库的行为,不表示所有使用它的应用都选择同样的启动模式、停止选项或布局。阅读上层应用时,要先找到它实际构造的是哪一种渲染器。
10.4 中文宽度与键盘输入的细节
终端宽度按单元格计算,JavaScript 字符串长度计算的却是编码单位。一个汉字往往占两列,组合字符和部分图形符号又可能包含多个字符编码。ANSI 样式控制码负责设置颜色,本身不占可见列数。直接按字符串长度截取,可能把字形切开或让终端自动折行,随后整幅画面都会错位。
Pi 使用字形簇分段与东亚宽度等规则计算显示宽度。字形簇可以简单理解为用户通常看成一个整体的字符单位。visibleWidth 测量可见列数,truncateToWidth 按列截断,wrapTextWithAnsi 在换行时保留样式。它们解决常见排版问题,但字形的最终显示仍受终端与字体影响,应用不能用字节数代替显示列数。
输入也不是“一次回调就是一个键”。终端可能把一条转义序列拆成几段,也可能把多个按键合并发送。StdinBuffer 先缓冲和组装完整序列,再交给键盘识别逻辑。协议协商可以启用 Kitty 键盘协议,并在合适情况下使用其他键盘模式;兼容方式取决于终端能力。应用应使用键绑定抽象,不把某个原始字节固定当成所有平台上的按键。
括号粘贴模式为一段粘贴加上开始和结束标记,使多行粘贴可以整体处理,避免其中的回车被误当成一次次提交。原始输入模式让程序直接处理按键,也意味着常见控制键需要应用正确处理。退出时恢复输入和光标状态,是界面可靠性的一部分,而不是可以忽略的收尾装饰。
中文输入法还依赖硬件光标位置来放候选框。Pi 的可聚焦输入组件会输出一个零宽光标标记,渲染器找到它,把真实光标定位过去;界面也可以用反色字符显示模拟光标。即使真实光标隐藏,位置仍有意义。包含输入框的容器若没有正确传递焦点,候选窗口就可能出现在错误位置。
10.5 小结与练习
流式终端界面的关键路径是“接收增量 → 更新组件内容 → 按宽度排版 → 比较前后行 → 写入终端”。把消息、组件与屏幕分开,才能同时处理持续增长的回答、窗口缩放和用户输入。
练习一:文字内容没变,窗口从八十列变成四十列,需要重新排版吗?参考答案:需要。换行位置改变,屏幕行不同,主屏实现也会因此完整重绘。
练习二:带颜色的“你好”能直接用字符串长度判断占几列吗?参考答案:不能。颜色控制码不占列,汉字列宽也不同于编码长度,应使用可见宽度工具。
练习三:为何粘贴多行文字不应按每个回车直接提交?参考答案:那会把一份粘贴误拆为多次输入;括号粘贴标记使程序能够识别整段内容。