Appearance
第23章 调度台与工位——pi-tui
本章导读: 源码路线第 ⑤ 站(产品层)。从核心层切到基础层的
pi-tui包,读tui-main-screen.ts、stdin-buffer.ts和utils.ts。这一站离开模型和循环,去看终端这块"画布"为什么不能随便涂——差分渲染、显示列、不完整的控制序列,各自管着什么。
小a 在自己的终端小工具里写了几行渲染代码,发现输出一会儿乱跳,一会儿闪烁,光标停在奇怪的位置。他盯着屏幕:“终端不是一块画布吗?我随便画不就行了?”
“问题就在这——终端不是一张可以随意覆写的画布。”老z说,“宽度按显示列计算,输入可能带着不完整的控制序列,旧终端未必支持新协议。想让你自己的界面稳定,得先看 pi-tui 怎么面对这些约束。”
本章沿 pi-tui 的渲染与输入路径阅读这些约束。源码证据均来自 pi-mono 提交 583f153d502aa8e958eefdb9af0fbd3344e68f95(包版本 0.83.0,2026-08-01);不覆盖全部内置组件、主题和图像协议。
组件不直接碰 stdin
“先从公共接口进。”老z打开 packages/tui/src/index.ts——它导出组件、TUI/Component 接口、TuiMainScreen、TuiAltScreen 与 ProcessTerminal。核心契约在 packages/tui/src/tui.ts 的 Component:组件以 render(width) 返回终端行,并可选实现 handleInput;TuiBase 负责焦点、覆盖层、输入监听和调度渲染。
比喻:调度台与工位
组件是工位,
TuiBase是调度台:工位只说自己要画成什么样(返回行数组),什么时刻刷新由调度台决定。
正常路径是下面这条简化调用链:
text
ProcessTerminal.start(onInput, onResize)
→ TuiBase.start() / requestRender()
→ TuiMainScreen.doRender() 或 TuiAltScreen.doRender()
→ 组件树的 render(width)
→ terminal.write(...ANSI 输出...)这是源码结构的概括,省略了覆盖层和调试分支。TuiBase.start() 把两条回调注册进 ProcessTerminal.start():data 走 handleTerminalInput,resize 走 requestRender()。调用链的一个关键点是——resize 回调本身不重绘,它只调 requestRender();真正决定全量重画还是差分更新的是稍后的 doRender()。requestRender() 有两条路,对应两种调用强度:
text
requestRender(force=false) requestRender(force=true)
→ 已请求过?return(合并进本次) → resetRenderState()(清空旧帧账本)
→ renderRequested = true → 清掉未执行的渲染定时器
→ process.nextTick(scheduleRender) → process.nextTick(doRender)
scheduleRender():
计算 delay = max(0, 16ms - 距上次渲染时间)
→ setTimeout(delay) 到点:
renderRequested = false
doRender() ← 全量还是差分在这里才见分晓
若 doRender 期间又来了请求 → 再 scheduleRender()“两条路差别在哪?”小a问。
“差别在 force 是否重置渲染状态。”老z说,“普通请求只是置位 renderRequested,靠 MIN_RENDER_INTERVAL_MS = 16 的最小间隔合并——16ms 窗口内来了十个请求也只算一次渲染;force 则连 previousLines 那些旧帧参照一起清掉,常用于主题变化或尺寸相关事件后的强制全量重建。注意 force 也先走 process.nextTick,不是同步刷屏。”
“那输入呢?聚焦组件怎么拿到数据?”
“handleTerminalInput 先让注册的监听器过一遍——监听器可返回 { consume: true } 截断、或 { data } 改写当前输入——再交给已聚焦组件,并顺带 requestRender()。”老z说,“两个细节:一是 key release 序列会被过滤,除非聚焦组件声明了 wantsKeyRelease;二是被聚焦组件若是覆盖层,还会先确认它仍可见,不可见就改派焦点。由此可以推断,组件不直接管理 stdin 或全局光标,而是共享这一层生命周期。”
“那我组件里想打印个日志调试怎么办?”小a问。
“你可以写 stdout,但一旦绕过渲染器,旧帧和光标账本就不再可信——屏幕上会出现你控制不了的残留。”老z说。
“这账本具体指什么?”
“TuiMainScreen 维护着 previousLines、cursorRow、hardwareCursorRow、maxLinesRendered、previousViewportTop 这一串状态,下一次差分更新全靠它们推算光标该往上还是往下挪。”老z说,“你直接 console.log 一行,终端光标会动,但这些字段一动不动。下一帧差分按旧账本计算 lineDiff,光标就被挪到一个错误的行——连锁反应就是闪烁和错位。”
“所以调试只能走渲染层。”
“对。”老z说,“debugRedraw 那条路径靠 PI_DEBUG_REDRAW=1 把 fullRender 的原因写进 pi-debug.log;PI_TUI_DEBUG=1 把每次渲染的完整 buffer dump 到 /tmp/tui。这两个都是渲染层内部的观测点,不破坏账本。”
主屏以旧帧为参照
小a:“为什么不干脆每次清屏重画?”
“全量重画实现直观,但输出量更大,滚动历史和视觉连续性都会变差。”老z说,“TuiMainScreen 的答案是以旧帧为参照:相同行不重写,变化行才通过光标移动、擦除和写入更新。”
ts
// packages/tui/src/tui-main-screen.ts,TuiMainScreen.doRender()(583f153d,结构化伪码,非逐行源码)
let newLines = this.render(width);
if (this.previousLines.length === 0 || widthChanged || heightChanged) {
fullRender(/* 是否清屏取决于分支 */);
} else {
// 找出 previousLines 与 newLines 的变化区间,移动光标、擦除并写入变化行
}
this.previousLines = newLines;“关键不在‘比较字符串’本身,而是比较前维护宽度与 ANSI 状态,比较后更新 previousLines。”老z指着伪码说,“resize 回调只是调用 requestRender();尺寸变化是否需要全量重绘由 doRender() 比较当前与先前宽高后决定,不能把这个判断归给回调参数。”
“为什么宽度变化一定要全量重画,高度变化也是吗?”
“宽高处理不一样。”老z说,“widthChanged 一律走 fullRender(true)——因为换行宽度变了,每一行可能折成不同的样子,差分算不出来。heightChanged 默认也全量,但有一个特例:isTermuxSession() 返回真时跳过全量。Termux 在软键盘弹出/收起时反复改高度,每次都全量重画会把整个历史重放一遍,体验崩溃。”
“内容缩短了怎么办?”
“旧行残留和尾部行清理也在这个函数里处理。”老z说,“getClearOnShrink() 为真且 newLines.length < maxLinesRendered 且没有覆盖层时,触发 fullRender(true) 清掉空行。maxLinesRendered 是个高水位——只增不减直到清屏,所以缩短会被识别。”
“那条‘超过宽度就抛错’的边界,到底卡的是什么?”
“卡的是组件契约的破坏。”老z说,“差分更新分支里,非图像行的 visibleWidth(line) > width 会写 pi-crash.log、调 stop()、再 throw。原因是差分更新靠 hardwareCursorRow 和 computeLineDiff 推算光标位移,一旦某行实际占了两行,所有后续行号就全错了——与其继续输出错位内容,不如尽早停。注意这只在差分更新分支:fullRender() 路径不查这个,因为它本身是全量重建。把这条检查外推成‘所有渲染都校验宽度’是不成立的。”
“那这个差分是普遍承诺吗?”
“它是已确认的实现事实,不保证所有终端都不会有视觉闪动。”老z补充,“该文件还在同步输出可用时成对发送 \x1b[?2026h/\x1b[?2026l,把一批更新包在一次同步区间中;发送失败会进入清理和停止路径。代码里同时保留了终端环境与图像行的特殊处理——终端输出并不总是普通文本行。”
把 doRender() 的分支摊开看,能更清楚地看出哪些判断归谁:
text
doRender() 分支顺序(583f153d,TuiMainScreen):
| 条件 | 动作 | 理由 |
| --- | --- | --- |
| 首次渲染(previousLines 为空) | fullRender(false),不清屏 | 假设进入时屏幕是干净的 |
| widthChanged | fullRender(true) | 换行宽度变了,每行折法都可能变 |
| heightChanged 且非 Termux | fullRender(true) | 视口要对齐;Termux 键盘弹收除外 |
| clearOnShrink 且内容缩短且无覆盖层 | fullRender(true) | 高水位回落时清掉旧行残留 |
| firstChanged >= newLines.length | 只清多余行,不重写 | 全部变化都是删除 |
| firstChanged < prevViewportTop | fullRender(true) | 差分只能碰当前视口内的行 |
| 其余 | 差分:清行、写变化行 | 最小化输出与闪烁 |“为什么 firstChanged < prevViewportTop 也要全量?”小a问。
“因为差分更新假设它‘从某行开始写、光标能一路跟着算’。”老z说,“一旦要改的行在视口上方,往上的光标位移就会把上一屏的内容卷回来——与其猜,不如重建。这个判断和‘超宽抛错’是同一类东西:差分是一条有前提的捷径,前提不成立就退回到全量或停止,而不是带着坏前提硬算。”
备用屏是视口,不是另一个开关
小a:“那备用屏(alt screen)呢?是不是另一个清屏重画的开关?”
“不是。”老z打开 packages/tui/src/tui-alt-screen.ts:
ts
override render(width: number): string[] {
return this.layoutRoot?.render(width) ?? super.render(width);
}
setLayoutRoot(component: Component | undefined): void {
this.layoutRoot = component;
this.requestRender();
}“TuiAltScreen 实现 ViewportTUI,首先选择布局根或普通组件树,再由 doRender() 对当前终端高度进行裁切和布局。setLayoutRoot 改变的是渲染状态,不是直接写屏——它维持与 TuiBase 相同的刷新入口。鼠标、滚轮、选区和滚动条逻辑也在这个类中处理。”
“它和主屏的差分逻辑一样吗?”
“不完全一样。”老z说,“主屏的差分在整棵行缓冲上做,行数可以超过终端高度、推入 scrollback。备用屏的 doRender() 先 renderLayoutFrame 算出布局,然后 screen.length > height 就 slice(screen.length - height) 裁到视口大小——它只画屏内那 height 行,用绝对定位 \x1b[${row+1};1H 而不是相对的 \x1b[B/A 光标位移。这就是为什么备用屏要 ENTER_ALT_SCREEN + DISABLE_AUTOWRAP:它假设自己对整个屏有完全控制权。”
“主屏和备用屏的差别是?”
“主屏尽力保留普通终端的已有内容;备用屏把界面作为受终端尺寸约束的视口。它们不是‘功能完全相同的两个开关’,而是针对不同呈现边界的两套实现——但共享 TuiBase 的焦点和输入调度。”
text
TuiMainScreen TuiAltScreen
参照 previousLines(整行缓冲) 参照 previousScreen(当前视口)
可推入 scrollback 只画 height 行,超出即 slice 裁掉
光标相对位移 \x1b[B / \x1b[A 绝对定位 \x1b[${row+1};1H
超宽行:差分分支抛错 超宽行:sliceByColumn(..., strict) 截断
无进出场协议 进场 ?1049h + 关 autowrap;出场 ?1049l
输入:键盘 + 可选监听 输入:键盘 + 鼠标 + 滚轮 + 选区 + 滚动条“为什么备用屏反而能截断而不是抛错?”
“因为它本来就只画视口内那 height 行,超宽意味着组件和视口宽度没对齐,与其让绝对定位把屏画花,不如把行截进视口宽度。”老z说,“而主屏差分靠光标账本推算位移,超宽会直接破坏后续所有行号——所以它选停止。同一个约束,两套实现选了不同的容错策略。”
“备用屏的鼠标、选区和滚动条也在 TuiAltScreen 里?”
“对。doRender() 先 renderLayoutFrame 布局,再合成覆盖层、裁到视口、叠选区、叠 flash,最后才写屏;输入侧用 handleViewportInput 处理 focus in/out、滚轮、滚动条拖拽和选区。”老z说,“代价是 beforeTerminalStop/afterTerminalStop 得手动收尾:关 autowrap、删 Kitty 图、把最后一份文档 dump 回主屏 scrollback。这套退出序列若被打断,主屏会留下半截内容。”
宽度不是字符串长度
小a:“那 render(width) 收到的 width,是字符串长度吗?”
“是显示列(display columns),不是字符数。”老z说,“packages/tui/src/utils.ts 的 visibleWidth()、sliceByColumn()、truncateToWidth() 和 wrapTextWithAnsi() 在 ANSI 序列、宽字符、组合字符和超链接控制序列存在时按显示列处理文本。packages/tui/src/layout.ts 用这些函数测量、裁切和绘制布局框;例如 renderLayoutFrame() 为滚动视图计算可见区域并绘制滚动条。”
“显示列和字符数的差别,举个例子?”
“中文‘中’是一个字符、两个码元、占两个显示列;emoji‘🦀’是两个码元、占两个显示列;带变体选择符的组合字符可能好几个码元、只占一列。”老z说,“如果按字符串长度算宽度,把‘🦀’当 2 列——碰巧对;但把带 ANSI 颜色码的 \x1b[31m红\x1b[0m 当 11 列就全错,那 9 个控制字符零宽。visibleWidth 走 grapheme 分割并扣掉 ANSI 序列,得出 1 列。”
text
输入 字符数 显示列
"abc" 3 3 (纯 ASCII 快路径直接返回)
"\x1b[31m红\x1b[0m" 11 1 (9 个控制字符零宽)
"中"(UTF-8) 1 2 (East Asian Width 为 2)
"🦀" 2 2 (代理对,宽 2)
"\t" 1 3 (实现按 3 个空格归一化)“所以 visibleWidth 不是简单地 str.length?”
“不是。它先走纯 ASCII 快路径,命中就直接返回长度;否则查宽度缓存,没命中就做三件事——把 tab 归一化成 3 个空格、用 extractAnsiCode 把 ANSI/OSC/APC 序列整段跳过、再对剩余文本做 grapheme 分割累加宽度。”老z说,“结果进一个有界的缓存。这套‘先快后慢、逐层降级’的顺序,也是渲染热路径里常见的取舍。”
“那中文被从中间切断怎么办?”
“sliceByColumn 用 getGraphemeCellRange 把列号映射回 grapheme 边界再切——不会把一个宽字符劈成两半。”老z说,“这就是为什么差分更新里那道‘超宽抛错’的硬边界存在:组件本该用这些函数自己截到 width,如果一行还是超了,说明组件没守契约,差分算出的光标位移就不可信了。”
要点:
visibleWidth()ANSI 序列零宽、宽字符占正确列数——所以“字符索引”和“终端列”没有被混为一谈。
边界路径: 在 TuiMainScreen.doRender() 的差分更新分支,普通渲染行若超过终端宽度会被视为渲染错误,记录诊断并停止,而不是让光标位置继续漂移。收益是尽早暴露差分更新中的组件错误,代价是第三方组件仍应遵守宽度契约。备用屏走的是另一条路:afterTerminalStop 里对超宽行 sliceByColumn(line, 0, width, true) 直接截断而非抛错——两套实现选择了不同的容错策略。
输入要先恢复消息边界
小a:“那输入呢?键盘按键不是一个个来的吗?”
“一次按键可能被拆成多个网络 chunk 到达,也可能一个 chunk 里带半条控制序列。”老z打开 packages/tui/src/stdin-buffer.ts:“StdinBuffer 识别 CSI、OSC、DCS、APC 等控制序列是否完整,只发出完整的片段;未完成序列保留在 buffer,不交给编辑器。这避免把一次分段到达的按键或终端回复误当作多个输入。”
“等不到完整序列怎么办?会一直卡住吗?”
“不会。”老z说,“process() 在 buffer 还有残留时设一个 setTimeout(timeoutMs),默认 10ms;到点调 flush() 把剩余内容当作一条序列强行吐出。这是个超时兜底——比如某个终端发了半个 ESC 再也没下文,10ms 后用户至少能看到那个 ESC,而不是输入彻底死掉。”
“还有别的边界吗?”
“有几条值得记。”老z说,“一是 bracketed paste:碰到 \x1b[200~ 进 paste 模式,把内容攒到 pasteBuffer 直到 \x1b[201~,整体从 paste 事件发出,不让中间的控制码被当成按键。二是高字节兼容:单字节 > 127 会被转成 ESC + (byte - 128),对应旧式 meta 键编码。三是 WezTerm 的 Kitty 键盘怪癖——它把 Escape 按下发成裸 \x1b、释放才发完整 CSI-u,buffer 检测到 \x1b\x1b 且下一个字符是 [/]/O/P/_ 时,只吐第一个 ESC 再从第二个重启,避免把释放序列误打字成普通文本。”
“聚焦组件拿到的是完整输入后才解释它。”老z继续,“components/input.ts 使用 grapheme 分割与显示列移动光标;components/editor.ts 的 Editor 处理多行、粘贴标记、撤销、补全、折行和输入事件。它们复用 visibleWidth(),因此字符索引与终端列始终分开。”
“StdinBuffer 判断‘完整’是按哪几类序列分别判的?”
“按前缀分五类,加上普通字符。”老z把判断表展开:
text
序列类别 判定“完整”的方式
CSI(ESC [ …) 末字节在 0x40–0x7E;SGR 鼠标 <数字;数字;数字[Mm] 需模式匹配
OSC(ESC ] …) 以 ST(ESC \)或 BEL(\x07)结尾
DCS(ESC P …) 以 ST 结尾(XTVersion 这类应答)
APC(ESC _ …) 以 ST 结尾(Kitty 图形应答)
SS3(ESC O x) ESC O 后跟一个字符
meta 键(ESC x) ESC 后跟一个字符
旧式鼠标(ESC[M…) ESC[M + 恰好 3 个字节“CSI 有个例外:末字节在范围内还不够,< 开头的 SGR 鼠标必须严格匹配 \d+;\d+;\d+[Mm],否则继续等。”老z说,“因为形如 \x1b[<35;20;5 的半截序列一旦被当成普通 CSI 放行,三个数字就会被拆成零散输入。判定逻辑把‘像完整的’和‘确实完整的’分开,宁可多等一个 chunk。”
输入、终端与宽度的责任表
| 状态或责任 | 负责符号 | 正常语义 | 失败或边界语义 |
|---|---|---|---|
| raw stdin 与 resize | ProcessTerminal.start() | 注册回调并进入终端模式 | stop() 必须恢复终端状态 |
| 控制序列分段 | StdinBuffer | 只发出完整序列 | 未完成序列保留在 buffer,不交给编辑器 |
| 聚焦输入 | TuiBase.handleInput() | 监听器后交给 focused component | release 事件可按组件要求过滤 |
| 显示列 | visibleWidth() | ANSI 零宽、宽字符占正确列数 | 超宽行触发诊断而非静默折断 |
| 可选能力 | terminal/image helpers | 有能力时使用增强协议 | 无能力时走文本或降级路径 |
packages/tui/src/terminal.ts 的 Terminal 是依赖倒置边界:TUI 只依赖最小终端能力,ProcessTerminal 是进程 stdin/stdout 的一种实现。终端颜色、Kitty 键盘、图像或同步输出是否可用,需要分别检测;不能从 Terminal 接口推断某一终端支持它们。
设计代价与未覆盖部分
“这套分层把终端 I/O、调度、布局和组件编辑行为隔开,便于替换或测试其中一层。”老z说,“代价是接口多、状态关联强——覆盖层、鼠标和 ANSI 状态下的缺陷,不容易靠字符串快照完全发现。”
“为什么字符串快照发现不了?”
“因为差分更新的输出依赖运行期账本:hardwareCursorRow、maxLinesRendered、previousViewportTop 这些是逐帧滚动的,单看某一帧的 buffer 字符串,你不知道它为什么从第 5 行开始写、为什么把光标挪到第 12 行。”老z说,“PI_DEBUG_REDRAW 和 PI_TUI_DEBUG 两个开关就是为了补这个——一个记 fullRender 的原因,一个 dump 每帧的完整上下文。图像能力、键位配置、Markdown 渲染和每个组件的细节不在本章覆盖范围;读者可从 index.ts 的导出表继续定位。”
“那这层设计到底省了什么、又让什么变贵了?”
“省的是接口稳定性:终端 I/O、调度、布局和组件编辑行为各管一段,替换 ProcessTerminal 或用测试替身注入终端都只碰一条边界。变贵的是推理成本——doRender() 里一个 widthChanged 判断的改动,影响面是整帧输出,而它依赖的旧帧状态分散在好几个私有字段里。这正是调试开关存在的理由:状态多到单靠肉眼读代码算不出,就得有观测点把中间态显式吐出来。”老z说,“如果你要自己造一个终端界面,值得复制的不是某个 ANSI 序列,而是‘旧帧参照 + 变化区间 + 光标账本’这三件套和配套的观测开关。”
小结
终端界面看似只是"输出几行文字",但 pi-tui 的稳定刷新背后是一组需要面对终端差异的状态机。它的稳定性来自四层设计:组件契约规定每个组件只负责 render(width) 返回行数组,不直接碰 stdin 或全局光标,何时刷新由调度层决定;主屏以旧帧为参照做差分更新,相同行不重写,只有变化的行才通过光标移动和擦除来更新,从而减少闪烁和输出量;备用屏则把界面当作受终端尺寸约束的视口,用布局根和裁切来呈现。宽度处理一律按显示列而非字符数——ANSI 控制序列零宽,宽字符占两列,这些差异由 visibleWidth 统一处理,避免光标位置因为"字符数"和"终端列"的混淆而漂移。
输入侧的麻烦在网络分帧:一次按键可能被拆成多个字节 chunk 到达,一个 chunk 里也可能带着半条控制序列。StdinBuffer 识别 CSI、OSC 等序列是否完整,只把完整的片段交给聚焦组件,避免把一次分段到达的按键误当成多次输入。而"是否完整"本身也分好几类——CSI 看末字节和 SGR 鼠标的严格模式,OSC/DCS/APC 看是否以 ST 结尾,SS3 和 meta 键看后缀长度——超时兜底保证残缺序列不会被无限期积压。
差分渲染这条捷径在几条前提下才成立:行必须不超宽、变化行必须在当前视口内、旧帧账本必须准确。任一前提不成立,代码要么退回全量重建,要么记录诊断后停止,而不是继续把光标往错误的位置挪。主屏和备用屏对同一个超宽问题甚至给出了相反的答案——一个抛错一个截断,因为它们的定位方式不同。这套分层的代价是接口多、状态关联强——覆盖层、鼠标和 ANSI 状态下的缺陷很难靠字符串快照完全发现,PI_DEBUG_REDRAW 与 PI_TUI_DEBUG 正是为此存在的观测点。
源码走查
在固定快照中依次打开 packages/tui/src/tui.ts 的 Component、TuiBase.start() 和输入分发;再读 tui-main-screen.ts 的 TuiMainScreen.doRender(),确认旧帧、宽度检查和终端写入如何衔接。最后用 visibleWidth()、StdinBuffer 与 Editor.handleInput() 追踪一段含宽字符和粘贴控制序列的输入,区分字节、字符和显示列。