#115 终端变窄提示符残影:为什么每次重开侧栏都多一行
figuretu/eyrie · main...fix/terminal-resize-prompt-ghost · 2026-07-06 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一份「修复类」走读,叙事围绕「原来错在哪、为什么会错」。
1TL;DR
只改了一个文件的一个 useEffect(外加它的测试)。修的现象是:在工作台里反复收起再打开左侧任务导航栏,终端里就会攒下一摞一模一样的提示符「路径行」——每重开一次多一行。
病根不在这个仓库,而在 xterm.js 与 shell 的一次配合失误。终端每次被 resize,内核会给 shell 发一个 SIGWINCH,shell(powerlevel10k)据此原地重绘提示符——它先存一次光标位置,重绘时再恢复回去、擦掉旧的、重画。这套动作本身没问题。问题是 xterm.js 5.5.0 在「变窄」重排时没把这个光标存档跟着迁移:面板过去是先把 xterm 重排、再通知 pty,于是 shell 的「恢复光标」落到了一个已经错位的行上,擦除起点偏低,旧提示符的第一行没被擦掉,留成了残影。
收起侧栏让终端变宽(不掉),重开侧栏让终端变窄(掉一行)——这正是「重开一次多一行」。
修法三条:① 只有尺寸真的变了才通知 pty(挡掉「宽高没变也发 resize」的空转 SIGWINCH);② 变宽时 xterm 与 pty 照旧同步重排(变宽从不会错位);③ 变窄时先通知 pty,让 shell 先为新宽度重绘,把 xterm 的重排推迟到那次重绘落定之后再做,绕开 xterm 那个存档缺陷。
全部改动集中在渲染进程终端面板一处;没有协议、daemon、数据模型的改动。测试占比过半,其中大部分是新增的三条 resize 行为回归测试 + 为它们改造的 mock。
2旅程:收起→重开侧栏(终端被 resize)
这条旅程从「用户点了导航栏的折叠钮」出发,走到「shell 在 pty 里重绘出一行残影」为止,再看新代码在这条链路的哪一环插手。走通之后,你会知道终端 resize 的责任边界在哪、以及为什么「变窄」是唯一出事的方向。
WorkbenchNav(unmount)→ 容器宽度变化→ ResizeObserver → fitAndResize
TerminalPanel.tsx→ connection.resize→ pty.resize → SIGWINCH
daemon terminal-ws→ p10k 重绘
你的 zsh
A.1一次 resize 是怎么走到 shell 的
任务导航栏折叠时不是缩动画,而是直接从 DOM 卸载(WorkbenchNav 在 collapsed 时 return null)。于是终端容器的宽度瞬间变化,容器上挂的 ResizeObserver 触发面板的 fitAndResize。这一环是纯渲染进程内的事,关键在它做完计算后往 pty 发的那一下 connection.resize:它经 WebSocket 到 daemon,daemon 对 node-pty 调 pty.resize(cols, rows)——而内核在收到这个 ioctl 时无条件给 shell 发一个 SIGWINCH,哪怕行列数其实没变。
shell 收到 SIGWINCH 后重绘提示符。powerlevel10k 的重绘是「存档—恢复」式的:它此前用 DECSC(终端转义序列 ESC 7)记下了提示符起点的光标位置,重绘时发 DECRC(ESC 8)跳回那个存档点,再发「清到屏幕底」并重画。整套动作是对的——在一个正常终端里它原地覆盖、不留痕。要记住的因果只有一条:每一次 pty resize = 一次 SIGWINCH = shell 一次「跳回存档点重绘」。谁存的档、存在哪、恢复时终端认不认,决定了会不会出残影。
A.2病根:xterm 在「变窄」重排时丢了光标存档
旧代码里,fitAndResize 每次都做两件事、且顺序是「先动 xterm,再通知 pty」:
const fitAndResize = () => {
// 已卸载则跳过(StrictMode 抛弃挂载 / 快速关闭),否则对已 dispose 的 terminal 取维度会抛
if (cancelled) return
fitAddon.fit() // ① 立刻把 xterm 重排到新宽度——变窄时这里会 reflow
connection.resize(terminal.cols, terminal.rows) // ② 之后才通知 pty → SIGWINCH → shell 跳回存档点重绘
}
const resizeObserver = new ResizeObserver(fitAndResize) // ③ 每次投递都跑一遍,宽高没变也照发 resize
resizeObserver.observe(host)
顺序是关键。fitAddon.fit() 内部会调 terminal.resize(),当列数变小(变窄),xterm 要把超宽的行重新折行(reflow),缓冲区里各行的绝对行号随之移动。但 xterm.js 5.5.0 不会把 shell 之前用 ESC 7 存下的那个光标位置一起迁移。等 ② 触发的 SIGWINCH 让 shell 发来 ESC 8「恢复光标」时,它恢复到的是一个已经因 reflow 而错位的绝对行——比真正的提示符顶端偏低一行。于是随后的「清到屏幕底」从偏低处开始清,旧提示符的第一行(路径 + git 状态那行、没有右侧 Py base、没有 ❯)被留在了上面,成为一条残影。
为什么只有「变窄」出事?变宽是把折行合并、行号不会把存档点顶到下方,shell 的 ESC 8 恢复仍然对得上,覆盖干净。所以:变宽零残影,变窄每次一条。这也解释了「先动 xterm 再通知 pty」这个顺序本身就是导火索——xterm 抢在 shell 的恢复动作之前 reflow,把存档点作废了。
A.3修法:把 grow 和 shrink 分成两条路
新的 fitAndResize 不再无脑「fit 完就发」。它先用 proposeDimensions() 只算出目标行列而不立刻应用,做一道去重,然后按「变宽 / 变窄」分流:
const fitAndResize = () => {
if (cancelled) return
const dims = fitAddon.proposeDimensions() // 只计算目标行列,先不动 xterm
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return
if (dims.cols === sentCols && dims.rows === sentRows) return // 去重:宽高没变就不发(否则一次空转 SIGWINCH = 一次多余重绘)
const shrinking = dims.cols < terminal.cols
sentCols = dims.cols
sentRows = dims.rows
connection.resize(dims.cols, dims.rows) // pty 先行:让 shell 先为新宽度重绘
if (shrinking) {
// 变窄:把 xterm 的 reflow 挂起,等 shell 那次重绘落定再做
pendingShrink = { cols: dims.cols, rows: dims.rows }
clearTimeout(shrinkTimer)
shrinkTimer = setTimeout(applyPendingShrink, shrinkReflowCapMs) // 兜底上限(见 A.4)
} else {
pendingShrink = null
terminal.resize(dims.cols, dims.rows) // 变宽 / 只变高:与 pty 同步 reflow,从不会错位
}
}
对照着看,这条旅程在「面板这一跳」的行为变了:
另外那道去重(sentCols/sentRows)和 rAF 合并(下面 scheduleFit)是配套的止血:旧代码在每次 ResizeObserver 投递、以及挂载时的两次 fit 里都会无条件发 resize,即便行列没变——每一发都是一次多余的 SIGWINCH。现在只有真变了才发,一次布局变化最多对应一次重绘。
let fitFrame = 0
const scheduleFit = () => {
if (cancelled || fitFrame) return
fitFrame = requestAnimationFrame(() => { // 一帧内的多次投递合并成一次 fit
fitFrame = 0
fitAndResize()
})
}
const resizeObserver = new ResizeObserver(scheduleFit) // 观察者现在挂 scheduleFit,不再直挂 fitAndResize
resizeObserver.observe(host)
A.4「延后」到底延后到什么时候
变窄时挂起的 reflow 不能定时死等一个固定毫秒——太短会在 shell 重绘还没到时就 reflow(残影照旧),太长则右侧空白晚收拢、看得出来。做法是盯着 pty 回来的数据流:shell 重绘的字节会分块从 pty 流回,面板在每收到一块数据时就把 reflow 的定时器往后推,直到数据流「静默」一小段才真正 reflow——也就是等这轮重绘的字节全到齐、缓冲区稳定成一条干净提示符,再让 xterm 去 reflow 它。
const settleShrinkOnData = () => {
if (!pendingShrink) return
clearTimeout(shrinkTimer)
// 每来一块重绘数据就把 reflow 往后推
shrinkTimer = setTimeout(applyPendingShrink, shrinkReflowQuietMs) // 静默满 40ms 才 reflow
}
// ……pty 数据订阅里,写完 xterm 后顺手推一下静默计时
const unsubscribeData = connection.onData((data) => {
terminal.write(data)
settleShrinkOnData()
})
两个时间常数各管一头,注释里写清了「为什么是这个数」:
// 变窄后 shell 会在 pty 的新尺寸上重绘;xterm 只有等这次重绘落定后才 reflow。
// 静默满这么久才 reflow —— 够长以熬过一次 loopback 往返,够短以让人察觉不到。
const shrinkReflowQuietMs = 40
// 无论如何不把 reflow 拖过这个上限,这样一次「不触发任何重绘」的变窄也能追上 pty。
const shrinkReflowCapMs = 250
applyPendingShrink 就是最后落地那一下:把挂起的目标尺寸真正 terminal.resize() 下去,并清掉 pendingShrink;它对「已卸载」和「被一次 grow 抢先清空」都做了空转保护。effect 的 cleanup 里也补了 cancelAnimationFrame(fitFrame) 和 clearTimeout(shrinkTimer),让挂起中的 fit 与 reflow 不会打到一个正在 dispose 的 terminal 上。
排查路标 · 终端 resize
| 症状 | 从哪下手 |
|---|---|
| 变窄后又冒出残影提示符 | TerminalPanel.tsx:fitAndResize 的 shrinking 分支 + settleShrinkOnData;确认 connection.resize 在 terminal.resize 之前、且 reflow 确实被挂起 |
| 变窄后 xterm 迟迟不贴合宽度(右侧留白久) | TerminalPanel.tsx:shrinkReflowQuietMs / shrinkReflowCapMs;数据是否一直不静默(onData 是否被别的流打断) |
| 拖动/动画式改宽时 pty 被 resize 轰炸 | TerminalPanel.tsx:scheduleFit 的 rAF 合并 + sentCols/sentRows 去重是否生效 |
| terminal 已关闭却仍触发 resize 报错 | TerminalPanel.tsx:cleanup 里的 cancelAnimationFrame(fitFrame) / clearTimeout(shrinkTimer) 与各处 cancelled 卫兵 |
3心智模型补丁
4新词表
| 这次改动里会反复出现的词 | |
|---|---|
SIGWINCH | 「窗口尺寸变了」的信号。内核在 pty 被 resize(TIOCSWINSZ ioctl)时发给 shell,不管行列是否真的变化——这就是为什么空转 resize 也会触发一次重绘。 |
DECSC / DECRC | 两条终端转义序列:ESC 7 存光标位置、ESC 8 恢复到存档位置。powerlevel10k 靠它做「原地重绘」;xterm 在变窄 reflow 时没迁移这个存档,恢复就落偏。 |
| reflow(重排) | xterm 在列数变化时把过宽的行重新折行。变窄会插入折行、抬高后续行的绝对行号;变宽会合并折行。 |
proposeDimensions() | fit 插件的「只算不改」版本:返回容器当前该用的行列,但不真去 terminal.resize()。新代码用它把「算尺寸」和「应用尺寸」拆开,才能在变窄时把后者推迟。 |
| shrink-defer(本 PR 造的说法) | 指「变窄时先通知 pty、把 xterm 的 reflow 推迟到 shell 重绘静默后」这套动作,由 pendingShrink + shrinkTimer + settleShrinkOnData 实现。 |
5测试与风险地图
测试文件新增了一个 describe('TerminalPanel resize'),用 rAF 同步桩 + 假定时器驱动,把三条行为钉死:
| 有兜底的(新增回归测试) | |
|---|---|
| 变宽同步 | forwards a grow to the pty and reflows xterm in the same tick —— 变宽时 pty 与 xterm 同一 tick 都收到新尺寸。 |
| 变窄延后 | holds a shrink reflow off xterm until the pty repaint settles —— 变窄时 pty 立刻收到、xterm 不动;喂入一块数据并推进静默计时后,xterm 才 reflow。 |
| 空转去重 | drops a redelivered layout change whose dimensions did not move —— 同尺寸重复投递不再二次发 resize。 |
① 用
@xterm/headless@5.5.0 + 真实 zsh/p10k 的 pty 复刻同一数据流,确定性证明旧代码每次变窄 +1、延后方案全程为 0;② 真机 Electron A/B(同一运行实例、仅用 HMR 切换本改动、CDP 驱动视口缩放):改前 5 次变窄 1→6(+5),改后 1→1(+0)。
这些实证不在门禁里,是一次性验证,不会拦住未来 xterm 升级后的行为回归。
6覆盖声明
PR 规模小(2 文件、非测试代码仅 TerminalPanel.tsx 一处 useEffect),未分发 subagent,全量精读。报告中的每段代码均亲自 Read:改动前版本取自 main(git show),改动后版本取自分支 HEAD(698f65ba)。SIGWINCH / DECSC-DECRC / xterm reflow 属 shell 与 xterm 库的行为,非本仓库代码,以文字说明,不贴外部源码。第 5 节所述的 headless 复刻与真机 A/B 为合并前的一次性验证结论,非门禁常驻。