#115 终端变窄提示符残影:为什么每次重开侧栏都多一行

figuretu/eyrie · main...fix/terminal-resize-prompt-ghost · 2026-07-06 · 自包含,读完即弃

1 commit
2 文件
+167 / −10
~54% 是测试代码
类型 · 修复

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。这是一份「修复类」走读,叙事围绕「原来错在哪、为什么会错」。

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 那个存档缺陷。

TerminalPanel.tsx
+76 / −6 · 生产代码
…Panel.test.tsx
+91 / −4 · 测试

全部改动集中在渲染进程终端面板一处;没有协议、daemon、数据模型的改动。测试占比过半,其中大部分是新增的三条 resize 行为回归测试 + 为它们改造的 mock。

2旅程:收起→重开侧栏(终端被 resize)

这条旅程从「用户点了导航栏的折叠钮」出发,走到「shell 在 pty 里重绘出一行残影」为止,再看新代码在这条链路的哪一环插手。走通之后,你会知道终端 resize 的责任边界在哪、以及为什么「变窄」是唯一出事的方向。

全景 · 只途经 1 个文件,但要理解 3 个跨进程环节
折叠钮
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)记下了提示符起点的光标位置,重绘时发 DECRCESC 8)跳回那个存档点,再发「清到屏幕底」并重画。整套动作是对的——在一个正常终端里它原地覆盖、不留痕。要记住的因果只有一条:每一次 pty resize = 一次 SIGWINCH = shell 一次「跳回存档点重绘」。谁存的档、存在哪、恢复时终端认不认,决定了会不会出残影。

A.2病根:xterm 在「变窄」重排时丢了光标存档

旧代码里,fitAndResize 每次都做两件事、且顺序是「先动 xterm,再通知 pty」:

apps/desktop/…/terminal/TerminalPanel.tsx(改动前 · main)真实代码(节选)
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()出目标行列而不立刻应用,做一道去重,然后按「变宽 / 变窄」分流:

apps/desktop/…/terminal/TerminalPanel.tsx(改动后 · HEAD)真实代码(节选)
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,从不会错位
  }
}

对照着看,这条旅程在「面板这一跳」的行为变了:

以前(无论宽窄,一条路)
ResizeObserver 触发
fitAddon.fit():xterm 立刻 reflow
connection.resize → SIGWINCH
shell 的 ESC 8 落到已错位的行 → 残影
现在(变窄这条路)
ResizeObserver 触发(经 rAF 合并)
proposeDimensions + 去重
connection.resize → SIGWINCH
shell 在旧宽度的 xterm 上干净重绘
重绘静默后 terminal.resize(reflow 已定稿的单条提示符,不错位)

另外那道去重(sentCols/sentRows)和 rAF 合并(下面 scheduleFit)是配套的止血:旧代码在每次 ResizeObserver 投递、以及挂载时的两次 fit 里都会无条件发 resize,即便行列没变——每一发都是一次多余的 SIGWINCH。现在只有真变了才发,一次布局变化最多对应一次重绘。

apps/desktop/…/terminal/TerminalPanel.tsx(改动后 · HEAD)真实代码(节选)
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 它。

apps/desktop/…/terminal/TerminalPanel.tsx(改动后 · HEAD)真实代码(节选)
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()
})

两个时间常数各管一头,注释里写清了「为什么是这个数」:

apps/desktop/…/terminal/TerminalPanel.tsx(模块顶部)真实代码(节选)
// 变窄后 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 上。

唯一代价:变窄那一下,xterm 会在旧(更宽)尺寸多停几十毫秒(正常情况 ≈ 静默 40ms;极端无重绘时最多 250ms)才收拢。方向是无害的——短暂多出一点右侧空白,绝不会截断内容;变宽路径完全不受影响。
排查路标 · 终端 resize
症状从哪下手
变窄后又冒出残影提示符TerminalPanel.tsxfitAndResizeshrinking 分支 + settleShrinkOnData;确认 connection.resizeterminal.resize 之前、且 reflow 确实被挂起
变窄后 xterm 迟迟不贴合宽度(右侧留白久)TerminalPanel.tsxshrinkReflowQuietMs / shrinkReflowCapMs;数据是否一直不静默(onData 是否被别的流打断)
拖动/动画式改宽时 pty 被 resize 轰炸TerminalPanel.tsxscheduleFit 的 rAF 合并 + sentCols/sentRows 去重是否生效
terminal 已关闭却仍触发 resize 报错TerminalPanel.tsx:cleanup 里的 cancelAnimationFrame(fitFrame) / clearTimeout(shrinkTimer) 与各处 cancelled 卫兵

3心智模型补丁

终端 resize = 「fit 一下 xterm,顺手告诉 pty 新尺寸」,一条路走到底。 resize 分两种:变宽/变高 xterm 与 pty 同步;变窄要 pty 先行、xterm 后到。
因为 xterm.js 5.5.0 只在「变窄 reflow」时丢失 shell 的光标存档,变宽不会。
terminal.resize 和 pty resize 必须严格同步,谁先谁后无所谓。 变窄时二者故意不同步:xterm 短暂停在旧宽度,等 shell 重绘落定再贴合。
这几十毫秒的错位是有意为之,不是 bug;它换来的是零残影。
ResizeObserver 每次投递都可以放心地往 pty 发一次 resize。 要先去重(宽高没变不发)+ rAF 合并;每次多余的 resize 都是一次多余的 SIGWINCH/重绘。

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.js 5.5.0 缓冲区在「变窄 reflow」时不迁移 saved-cursor 这一上游行为;单测用的是 mock 的 Terminal/FitAddon,覆盖真实 xterm 的 reflow。这条「变窄不留残影」的端到端保证,靠的是仓库外的验证:
① 用 @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:改动前版本取自 maingit show),改动后版本取自分支 HEAD(698f65ba)。SIGWINCH / DECSC-DECRC / xterm reflow 属 shell 与 xterm 库的行为,非本仓库代码,以文字说明,不贴外部源码。第 5 节所述的 headless 复刻与真机 A/B 为合并前的一次性验证结论,非门禁常驻。