#115:终端变窄时,别让 shell 的 prompt 留下幽灵

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

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

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

1TL;DR

反复收起 / 展开左侧「任务」栏,终端每来回一次就在历史里多堆一条一模一样的 prompt 幽灵行。根因不在 daemon、也不在 xterm 本身,而是「谁先动」的时序错了:面板过去在同一拍里既让 xterm 立刻按新宽度重折(reflow)、又把 resize 发给 pty;变窄那一刻 xterm 的重折先发生,把 shell 待会儿要用的「保存的光标位置」冲掉了,等 shell 的 prompt 重绘落地时,光标已经对不上,旧 prompt 的首行就被落在原地成了幽灵。

这个 PR 把面板的 resize 逻辑拆成三条纪律: 尺寸没真变就不发(一次 no-op resize 也会触发 shell 重绘); 变宽(grow)无害,xterm 照旧跟 pty 同拍重折; 变窄(shrink)时先把新尺寸喂给 pty,把 xterm 的重折押后,等 shell 那波 prompt 重绘的字节流安静下来再做——这时重折的是一个已经稳定的单行 prompt,没有光标可搁浅。改动集中在一个文件的一个 effect 里,另一半 diff 是三条新测试。

2旅程:收起侧栏 → 终端变窄 → (不再有)幽灵

顺着「用户点了折叠侧栏那半圆拉手」这一个动作走一遍,看这次改动把哪一环的时序掰正了。整条链路只落在渲染进程的一个组件里,daemon 侧只是忠实地把尺寸转给 pty,不产生也不放大幽灵。

全景 · 涉及 1 个文件(外加它到 daemon 的既有链路)
折叠侧栏
nav-store.ts
host 宽度变
ResizeObserver
算目标尺寸 + 判方向
TerminalPanel.tsx · fitAndResize
发 resize 给 pty 等 repaint 安静
settleShrinkOnData
xterm 重折
applyPendingShrink

A.1幽灵是谁画的:SIGWINCH、DECSC/DECRC 与一次会丢光标的重折

先把三个事实摆清楚,幽灵的成因就是这三者撞在一起。

事实一:改 pty 尺寸必然惊动 shell。面板把新的 cols/rows 发给 daemon,daemon 调 pty.resize(),内核对这个 TTY 发出 SIGWINCH 信号。关键点是:即便新尺寸和旧尺寸一模一样,这次 ioctl 仍然会发 SIGWINCH——所以「没必要的 resize」不是免费的。

事实二:shell 用「存光标 / 复原光标」包住每次重绘。截图里的 prompt 是 powerlevel10k。它响应 SIGWINCH 的方式是就地重绘 prompt:重绘前用 DECSC(ESC 7)保存当前光标位置,重绘完用 DECRC(ESC 8)把光标复原回去。正常情况下这一存一取严丝合缝,你根本看不见重绘发生。

事实三:xterm 的宽度重折不搬运那个「保存的光标」。xterm 在宽度变化时会把缓冲区重新折行(reflow)。变窄时,原来不换行的行被重新拆折,行号整体挪位——而 DECSC 存下来的那个坐标不会跟着挪。于是等 shell 的 DECRC 复原到达,它把光标放回了一个已经失效的位置,旧 prompt 的首行就此留在原地,成为幽灵。变宽(grow)是把折行摊平,光标不会被搁浅,所以只有变窄会中招。

这段因果,被直接写进了改动处的文件头注释,作为「为什么要这么绕」的真相源:

apps/desktop/src/renderer/features/terminal/TerminalPanel.tsx真实代码(fitAndResize 前的说明注释)
// Every pty resize delivers a SIGWINCH the shell answers by repainting its prompt in place
// (powerlevel10k saves the cursor with DECSC and restores it with DECRC on each repaint). xterm does
// not carry that saved cursor across a width *shrink* reflow, so reflowing xterm before the repaint
// lands makes the restore miss and strands the old prompt's first line as a ghost. Two guards: forward
// only a genuine size change (a no-op resize still fires SIGWINCH), and on a shrink size the pty
// first, then hold xterm's reflow until its repaint goes quiet. Growing never strands the cursor, so
// it reflows in step with the pty.

A.2老代码为何必然复现:同一拍里既重折又通知 pty

看修复前的 fitAndResize,它做两件事,且顺序上把幽灵坐实了。

TerminalPanel.tsx · main 分支(修复前)真实代码
const fitAndResize = () => {
  // ...disposed 保护省略...
  if (cancelled) return
  fitAddon.fit()                                   // ① 立刻把 xterm 重折到新尺寸(含变窄重折)
  connection.resize(terminal.cols, terminal.rows)  // ② 再把已 fit 后的尺寸发给 pty
}
const resizeObserver = new ResizeObserver(fitAndResize)  // 每次尺寸事件直接触发,无合并、无去重

两个毛病叠加。第一,fitAddon.fit() 会当场调 terminal.resize() 完成重折——变窄的那次重折就在这里发生,先于 第 ② 步引发的 shell 重绘回流。等 shell 的 DECRC 字节回到 xterm 时,重折早已把保存的光标冲掉了,幽灵已成定局。

第二,ResizeObserver 的回调直接就是 fitAndResize,且第 ② 步无条件用 terminal.cols/rows 发送——哪怕这次事件的尺寸跟上次一字不差,也照发不误。结合事实一(no-op resize 也发 SIGWINCH),折叠动画途中每一个中间宽度、乃至同宽度的重复投递,都在给 shell 递刀。

A.3第一道闸:尺寸没真变,就一个字节都不发

新代码不再直接读 terminal.cols,而是先用 proposeDimensions() 算出「这个 host 该是多大」——只算不改。算出来后跟上一次真正发出去的尺寸比对,一致就地返回。

TerminalPanel.tsx · fix 分支(修复后,节选前半)真实代码
let sentCols = 0
let sentRows = 0
const fitAndResize = () => {
  if (cancelled) return
  const dims = fitAddon.proposeDimensions()                          // 只算目标 cols/rows,不 resize 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                        // 拿「即将成为的宽」比「当前 xterm 的宽」判方向
  sentCols = dims.cols
  sentRows = dims.rows
  connection.resize(dims.cols, dims.rows)                            // pty 永远先收到新尺寸
  // ...按 shrinking 分流,见 A.4...
}

这里有个容易被略过的细节:判方向用的是 dims.cols < terminal.cols——左边是「即将要变成的宽度」,右边是「xterm 此刻还没改的宽度」。因为 proposeDimensions() 不动 xterm,terminal.cols 在这一刻仍是旧值,两者相减才是真实的缩放方向。也正因如此,pty 那行 connection.resize(dims.cols, dims.rows) 发的是刚算出的目标值,而不是修复前那种「已经被 fit 改过的」值。

A.4第二道闸:变窄时先喂 pty,等它的 repaint 安静了再重折 xterm

分流的核心在这里。变宽走老路——立刻重折,跟 pty 同拍,因为变宽不会搁浅光标。变窄则不马上重折 xterm:记下这次待办的 shrink,武装一个「兜底上限」计时器,然后把重折交给数据流去择时。

TerminalPanel.tsx · fitAndResize 的分流尾段真实代码
if (shrinking) {
  // arm a cap so a shrink that repaints nothing still catches up; each repaint chunk then shortens
  // the wait via settleShrinkOnData
  pendingShrink = { cols: dims.cols, rows: dims.rows }
  clearTimeout(shrinkTimer)
  shrinkTimer = setTimeout(applyPendingShrink, shrinkReflowCapMs)   // 上限 250ms 兜底
} else {
  pendingShrink = null
  terminal.resize(dims.cols, dims.rows)                            // 变宽:跟 pty 同拍,安全
}

那「等安静」是怎么落地的?pty 收到 resize 后,shell 的 prompt 重绘会以字节流回到面板。面板的数据回调在写进 xterm 之余,多调了一步 settleShrinkOnData——每来一块重绘数据,就把重折再往后推一点点;只有当这波重绘字节「静默」够久,才真正重折。

TerminalPanel.tsx · 数据回调 + settle/apply 两个助手真实代码
const unsubscribeData = connection.onData((data) => {
  terminal.write(data)
  settleShrinkOnData()        // 每块 repaint 数据都重置「安静」计时
})

// Reflow xterm to a now-settled prompt instead of racing the shell's saved-cursor restore ...
const applyPendingShrink = () => {
  if (cancelled || !pendingShrink) return
  const { cols, rows } = pendingShrink
  pendingShrink = null
  terminal.resize(cols, rows)   // 此刻 prompt 已稳定,没有 saved cursor 可搁浅
}
// Each repaint chunk pushes the reflow out, so xterm reflows only after the repaint burst goes quiet.
const settleShrinkOnData = () => {
  if (!pendingShrink) return
  clearTimeout(shrinkTimer)
  shrinkTimer = setTimeout(applyPendingShrink, shrinkReflowQuietMs)   // 安静 40ms → 重折
}

两个时间常数各司其职,都在文件顶部写明了取值理由:shrinkReflowQuietMs = 40 是「重绘流安静多久算落定」——要长到能熬过一次 loopback 往返,又短到肉眼无感;shrinkReflowCapMs = 250 是硬上限,防的是「这次变窄压根没触发任何重绘」的情形——没有数据来喂 settle,就靠这个兜底让 xterm 最终追上 pty,不至于卡在旧宽度。

把修复前后的变窄时序并排,差别一目了然:

以前(变窄)
host 变窄,ResizeObserver 触发
xterm 立刻重折(fit 内 resize)
发 resize 给 pty → SIGWINCH
shell 重绘:DECRC 复原到失效光标
旧 prompt 首行搁浅 = 幽灵
现在(变窄)
host 变窄,rAF 合并成一次 fit
尺寸真变 → 只发 resize 给 pty
shell 重绘字节回流,xterm 暂不重折
重绘安静 40ms(或 250ms 兜底)
此时才重折:prompt 已稳定,无幽灵

A.5第三道闸:把一波尺寸事件合并成一帧一次 fit

折叠是有动画的,布局要跨好几帧才稳定,ResizeObserver 会连珠炮式投递中间宽度。若每一发都独立走一遍 fitAndResize,那些中间宽度就会各自作为一次 resize 打到 pty 上。新代码用 requestAnimationFrame 把一帧内的多次投递合并成一次 fit。

TerminalPanel.tsx · scheduleFit(ResizeObserver 的实际回调)真实代码
let fitFrame = 0
const scheduleFit = () => {
  if (cancelled || fitFrame) return          // 本帧已排过就不再排,天然去重一帧内的连发
  fitFrame = requestAnimationFrame(() => {
    fitFrame = 0
    fitAndResize()
  })
}
const resizeObserver = new ResizeObserver(scheduleFit)   // 观察者接 scheduleFit,不再直接接 fitAndResize

这一层跟 A.3 的去重是互补的:rAF 合并砍掉「同一帧的连发」,A.3 的 sentCols/sentRows 比对砍掉「跨帧但尺寸没变」的重复。effect 卸载时,清理段新增了 cancelAnimationFrame(fitFrame)clearTimeout(shrinkTimer),让挂起的 fit 和挂起的 shrink 重折都不会打在一个已销毁的终端上。

排查路标 · 变窄旅程
症状从哪下手
变窄后又冒出幽灵 prompt 行TerminalPanel.tsxfitAndResizeshrinking 分支是否真把 terminal.resize 押后了;applyPendingShrink 是否被过早触发
变窄后终端内容错位 / 残影,但非整条 promptshrinkReflowQuietMs(40ms):repaint 没在这个窗口内到达就会过早重折——loopback 慢或远程 daemon 时可能不够
变窄后 xterm 迟迟不重折,视觉卡在旧宽度shrinkReflowCapMs(250ms)上限没触发,或这次 shrink 没有任何 repaint 来喂 settleShrinkOnData
拖拽 / 动画时 pty 收到一串中间尺寸scheduleFit 的 rAF 合并;以及 sentCols/sentRows 去重是否被绕过
变宽也出问题变宽走的是 else 同拍分支,跟 pty 一起重折——问题多半在 proposeDimensions() 返回值或 host 布局,而非本 PR 的 shrink 逻辑

3心智模型补丁

读这个文件时,以前能做的三个假设现在得改:

xterm 的 fit 和 pty 的 resize 是同一拍同步做的。 只有变宽才同步;变窄时 xterm 的重折被押后到 shell 的 prompt 重绘落定之后,两者刻意错开。
因为变窄的重折会冲掉 shell 待复原的光标,必须让 shell 先画完。
每次 ResizeObserver 投递都会把当前尺寸发给 pty。 先 rAF 合并成一帧一次,再跟上次发出的尺寸比对,只有真变了才发。
一次 no-op 的 pty resize 也会触发 SIGWINCH,进而触发一次多余的 shell 重绘。
要知道终端当前多大,读 terminal.cols / terminal.rows 「目标尺寸」得用 fitAddon.proposeDimensions() 现算;terminal.cols 是「xterm 当前的、可能还没更新的」值,如今被专门用来判缩放方向。

4新词表

终端 / 信号
SIGWINCH内核在 TTY 窗口尺寸变化(TIOCSWINSZ ioctl)时发给前台进程的信号;即使新旧尺寸相同,调用 resize 也会发。
DECSC / DECRC终端控制序列 ESC 7 / ESC 8,保存 / 复原光标位置。powerlevel10k 用这一对包住每次 prompt 重绘。
reflow(重折)xterm 在宽度变化时对缓冲区重新折行。变窄时的重折会让先前 DECSC 存下的光标坐标失效——这正是幽灵的直接成因。
本 PR 引入的机制
proposeDimensions()FitAddon 的方法:只算出目标 cols/rows,不真正 resize。fit() ≈ 它 + 一次 terminal.resize();本 PR 拆开二者以掌控重折时机。
settle-on-data(数据静默去抖)把 xterm 重折推迟到「输入流安静下来」的策略:每来一块重绘数据就重置计时(shrinkReflowQuietMs),静默够久才重折;shrinkReflowCapMs 是硬上限兜底。

5测试与风险地图

非测试改动只有 82 行,另外 95 行是三条新测试——占比约一半,行为被钉得比较实。

有兜底的(测试钉住的行为)

薄冰(无测试直接兜底,验收 / 复发时留意)

合并前留意:这是 UI 时序修复,五门(typecheck/lint/test/build/check)之外的真正验收是真机手感——反复收放侧栏看幽灵是否绝迹、变窄后终端内容是否对齐、拖拽时 pty 是否只收到落定尺寸。真机自测已过,评审可据路标表复核这几点。

6覆盖声明

本 PR 仅 2 文件、+167/−10,属「小 PR」,未分发 subagent,全量逐行精读。报告中每段代码均取自亲自 Read 过的 TerminalPanel.tsx(fix 分支)与其 main 侧旧版、及 TerminalPanel.test.tsx,非二手转述。测试小节只读了三条新测试的名称与意图,未逐行精读其 setup。daemon 侧 pty.resize 链路为既有代码、本 PR 未改,仅作为背景引用未展开。