源码篇第6章 调度台与工位——pi-tui

本章导读: 源码路线第 ⑤ 站(产品层)。从核心层切到基础层的
pi-tui包,读tui-main-screen.ts、stdin-buffer.ts和utils.ts。这一站离开模型和循环,去看终端这块“画布”为什么不能随便涂:差分渲染、显示列、不完整的控制序列,各自管着什么。
小A在自己的终端小工具里写了几行渲染代码,发现输出一会儿乱跳,一会儿闪烁,光标停在奇怪的位置。他盯着屏幕:“终端不是一块画布吗?我随便画不就行了?”
“问题就在这:终端不是一张可以随意覆写的画布。”老Z说,“宽度按显示列计算,输入可能带着不完整的控制序列,旧终端未必支持新协议。想让你自己的界面稳定,得先看 pi-tui 怎么面对这些约束。”
本章沿 pi-tui 的渲染与输入路径阅读这些约束。源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32,包版本 1.0.0,2026-10-01);不覆盖全部内置组件、主题和图像协议。
组件不直接碰 stdin
“先从公共接口进。”老Z打开 packages/tui/src/index.ts,里面导出组件、TUI/Component 接口、TuiMainScreen、TuiAltScreen 与 ProcessTerminal。核心契约在 packages/tui/src/tui.ts 的 Component:组件以 render(width) 返回终端行,并可选实现 handleInput;TuiBase 负责焦点、覆盖层、输入监听和调度渲染。
比喻:调度台与工位
组件是工位,
TuiBase是调度台:工位只说自己要画成什么样(返回行数组),什么时刻刷新由调度台决定。
正常路径是下面这条简化调用链:
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() 有两条路,对应两种调用强度:
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()“两条路的差别我猜在 force:它跳过合并,直接刷?”
“只对了一半,force 还会清旧账本。”老Z说,“它先 resetRenderState(),把 previousLines 那些旧帧参照一起清掉,常用于主题变化或尺寸相关事件后的强制全量重建。普通请求才是只置位 renderRequested,靠 MIN_RENDER_INTERVAL_MS = 16 的最小间隔合并,16ms 窗口内来了十个请求也只算一次渲染。还有,force 也先走 process.nextTick,不是同步刷屏。”
“那输入呢?聚焦组件怎么拿到数据?”
“handleTerminalInput 先让注册的监听器过一遍,监听器可返回 { consume: true } 截断、或 { data } 改写当前输入,再交给已聚焦组件,并顺带 requestRender()。”老Z说,“两个细节:一是 key release 序列会被过滤,除非聚焦组件声明了 wantsKeyRelease;二是被聚焦组件若是覆盖层,还会先确认它仍可见,不可见就改派焦点。所以组件不直接管理 stdin 和全局光标,它们共享这一层生命周期。”
“我在组件里想打印个日志调试怎么办?”
“你可以写 stdout,但一旦绕过渲染器,旧帧和光标账本就不再可信,屏幕上会出现你控制不了的残留。”老Z说。
“这账本具体指什么?”
“TuiMainScreen 维护着 previousLines、cursorRow、hardwareCursorRow、maxLinesRendered、previousViewportTop 这一串状态,下一次差分更新全靠它们推算光标该往上还是往下挪。”老Z说,“你直接 console.log 一行,终端光标会动,这些字段却一动不动。下一帧差分按旧账本计算 lineDiff,光标就被挪到一个错误的行,接着就是闪烁和错位。”
“所以调试只能走渲染层。”
“对。”老Z说,“PI_TUI_DEBUG_REDRAW=1 把 fullRender 的原因追加到日志目录下的 pi-tui-debug.log,前提是宿主构造 TUI 时给了 logDirectory,没给就不记;PI_TUI_DEBUG=1 把每次差分渲染的上下文 dump 到 /tmp/tui。这两个都是渲染层内部的观测点,不破坏账本。”
主屏以旧帧为参照
小A:“为什么不干脆每次清屏重画?”
“全量重画实现直观,但输出量更大,滚动历史和视觉连续性都会变差。”老Z说,“TuiMainScreen 的答案是以旧帧为参照:相同行不重写,变化行才通过光标移动、擦除和写入更新。”
// packages/tui/src/tui-main-screen.ts,TuiMainScreen.doRender()(v1.0.0,结构化伪码,非逐行源码)
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-tui-crash.log(没有日志目录时写到系统临时目录)、调 stop()、再 throw。原因是差分更新靠 hardwareCursorRow 和 computeLineDiff 推算光标位移,一旦某行实际占了两行,后面所有行号就全错了,与其继续输出错位内容,不如尽早停。这条检查只在差分更新分支有:fullRender() 路径不查,它本身就是全量重建。”
“差分是对所有终端的承诺吗?”
“它是已确认的实现事实,不保证所有终端都不会有视觉闪动。”老Z补充,“该文件还成对发送 \x1b[?2026h/\x1b[?2026l,把一批更新包在一次同步区间中。v1.0.0 不再把整帧拼成一个字符串一次写出,而是经 BoundedTerminalWriter 按 1 MiB 分块写,切块时不拆开 UTF-16 代理对;注释说明这是为了全量重绘时不生成超过 V8 上限的大字符串。代码里同时保留了终端环境与图像行的特殊处理,终端输出并不总是普通文本行。”
把 doRender() 的分支摊开看,能更清楚地看出哪些判断归谁:
doRender() 分支顺序(v1.0.0,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:
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。”
TuiMainScreen TuiAltScreen
参照 previousLines(整行缓冲) 参照 previousScreen(当前视口)
可推入 scrollback 只画 height 行,超出即 slice 裁掉
光标相对位移 \x1b[B / \x1b[A 绝对定位 \x1b[${row+1};1H
超宽行:差分分支抛错 超宽行:sliceByColumn(..., strict) 截断
无进出场协议 进场 ?1049h + 关 autowrap;出场 ?1049l
输入:键盘 + 可选监听 输入:键盘 + 鼠标 + 滚轮 + 选区 + 滚动条“为什么备用屏反而能截断而不是抛错?”
“超宽说明组件和视口宽度没对齐,与其让绝对定位把屏画花,不如把行截进视口宽度。”老Z说,“主屏差分靠光标账本推算位移,超宽会直接破坏后续所有行号,所以它选停止。同一个约束,两套实现选了不同的容错策略。”
“备用屏的鼠标、选区和滚动条也在 TuiAltScreen 里?”
“对。doRender() 先 renderLayoutFrame 布局,再合成覆盖层、裁到视口、叠选区、叠 flash,最后才写屏;输入侧用 handleViewportInput 处理 focus in/out、滚轮、滚动条拖拽和选区。”老Z说,“代价是 beforeTerminalStop/afterTerminalStop 得手动收尾:关掉搜索、选区和滚动条拖拽,删 Kitty 图、关鼠标上报、重新开启 autowrap;退出备用屏后再把最后一份文档按终端宽度截断,dump 回主屏 scrollback。v1.0.0 加了 preserveScreen 选项,交给另一个 TUI 接管同一终端时跳过这次 dump。这套退出序列若被打断,主屏会留下半截内容。”
宽度不是字符串长度
小A:“render(width) 收到的 width,是字符串长度吗?”
“是显示列(display columns),不是字符数。”老Z说,“packages/tui/src/utils.ts 的 visibleWidth()、sliceByColumn()、truncateToWidth() 和 wrapTextWithAnsi() 在 ANSI 序列、宽字符、组合字符和超链接控制序列存在时按显示列处理文本。packages/tui/src/layout.ts 用这些函数测量、裁切和绘制布局框;例如 renderLayoutFrame() 为滚动视图计算可见区域并绘制滚动条。”
“显示列和字符数的差别,举个例子?”
“中文‘中’是一个字符、一个 UTF-16 码元、占两个显示列;emoji‘🦀’是两个 UTF-16 码元、占两个显示列;带变体选择符的组合字符可能好几个码元、只占一列。”老Z说,“按字符串长度算宽度,把‘🦀’当 2 列,碰巧对;把带 ANSI 颜色码的 \x1b[31m红\x1b[0m 当 10 列就全错,那 9 个控制码元零宽。visibleWidth 去掉 ANSI 序列并按 grapheme 计算宽度,得出 2 列。”
输入 UTF-16 码元数 显示列
"abc" 3 3 (ASCII 快路径直接返回)
"\x1b[31m红\x1b[0m" 10 2 (9 个控制码元零宽)
"中"(UTF-16) 1 2 (East Asian Width 为 2)
"🦀" 2 2 (代理对,宽 2)
"\t" 1 3 (实现按 3 个空格归一化)“所以 visibleWidth 不是简单地 str.length?”
“不是。它先走 ASCII 快路径:可打印 ASCII 每个算 1 列,tab 算 3 列,ANSI 序列整段跳过,遇到其他字符才放弃。注释说明这样换主题重绘时,带颜色的纯 ASCII 行不用做 grapheme 分割。快路径不命中就查宽度缓存,没命中再做三件事:把 tab 归一化成 3 个空格、用 extractAnsiCode 把 ANSI/OSC/APC 序列整段跳过、再对剩余文本做 grapheme 分割累加宽度。”老Z说,“结果进一个有界的缓存。这套‘先快后慢、逐层降级’的顺序,也是渲染热路径里常见的取舍。”
“中文要是被从中间切断呢?”
“sliceByColumn 用 getGraphemeCellRange 把列号映射回 grapheme 边界再切,不会把一个宽字符劈成两半。”老Z说,“这就是为什么差分更新里那道‘超宽抛错’的硬边界存在:组件本该用这些函数自己截到 width,如果一行还是超了,说明组件没守契约,差分算出的光标位移就不可信了。”
要点:
visibleWidth()ANSI 序列零宽、宽字符占正确列数,“字符索引”和“终端列”因此没有被混为一谈。
边界路径上备用屏还有一处不同:超宽行在 afterTerminalStop 里用 sliceByColumn(line, 0, width, true) 直接截断,不抛错。
输入要先恢复消息边界
小A:“键盘按键总是一个个到的吧?”
“不一定。”老Z打开 packages/tui/src/stdin-buffer.ts,“一次按键可能被拆成多个网络 chunk 到达,也可能一个 chunk 里带半条控制序列。StdinBuffer 识别 CSI、OSC、DCS、APC 等控制序列是否完整,只发出完整的片段;未完成序列保留在 buffer,不交给编辑器,这避免把一次分段到达的按键或终端回复误当作多个输入。”
“等不到完整序列怎么办?会一直卡住吗?”
“不会。”老Z说,“process() 在 buffer 还有残留时设一个定时器,到点调 flush() 把剩余内容当作一条序列强行吐出。v1.0.0 分两种等待时间:残留只是一个裸 ESC 时用 escapeTimeout,默认 10ms,到点就当作 Escape 键;其他未完成序列,例如 CSI 或鼠标序列,用 timeout,默认 50ms。注释建议 SSH 这类高延迟环境调大 escapeTimeout,免得 Alt+键被拆成 Escape 加普通字符。这是个超时兜底,终端发了半条序列再也没下文时,用户至少能看到输入,而不是输入彻底死掉。”
“还有别的边界吗?”
“有几条值得记。”老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把判断表展开:
序列类别 判定“完整”的方式
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_TUI_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_TUI_DEBUG_REDRAW 与 PI_TUI_DEBUG 正是为此存在的观测点。
源码走查
在固定快照中依次打开 packages/tui/src/tui.ts 的 Component、TuiBase.start() 和输入分发;再读 tui-main-screen.ts 的 TuiMainScreen.doRender(),确认旧帧、宽度检查和终端写入如何衔接。最后用 visibleWidth()、StdinBuffer 与 Editor.handleInput() 追踪一段含宽字符和粘贴控制序列的输入,区分字节、字符和显示列。
