#115:终端变窄时,别让 shell 的 prompt 留下幽灵
figuretu/eyrie · main...fix/terminal-resize-prompt-ghost · 2026-07-06 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。旅程结尾有一张「排查路标」:将来复发时,症状对应去哪个文件看哪个函数。这是一个「修复」型 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,不产生也不放大幽灵。
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)是把折行摊平,光标不会被搁浅,所以只有变窄会中招。
这段因果,被直接写进了改动处的文件头注释,作为「为什么要这么绕」的真相源:
// 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,它做两件事,且顺序上把幽灵坐实了。
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 该是多大」——只算不改。算出来后跟上一次真正发出去的尺寸比对,一致就地返回。
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,武装一个「兜底上限」计时器,然后把重折交给数据流去择时。
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——每来一块重绘数据,就把重折再往后推一点点;只有当这波重绘字节「静默」够久,才真正重折。
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,不至于卡在旧宽度。
把修复前后的变窄时序并排,差别一目了然:
A.5第三道闸:把一波尺寸事件合并成一帧一次 fit
折叠是有动画的,布局要跨好几帧才稳定,ResizeObserver 会连珠炮式投递中间宽度。若每一发都独立走一遍 fitAndResize,那些中间宽度就会各自作为一次 resize 打到 pty 上。新代码用 requestAnimationFrame 把一帧内的多次投递合并成一次 fit。
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.tsx:fitAndResize 的 shrinking 分支是否真把 terminal.resize 押后了;applyPendingShrink 是否被过早触发 |
| 变窄后终端内容错位 / 残影,但非整条 prompt | shrinkReflowQuietMs(40ms):repaint 没在这个窗口内到达就会过早重折——loopback 慢或远程 daemon 时可能不够 |
| 变窄后 xterm 迟迟不重折,视觉卡在旧宽度 | shrinkReflowCapMs(250ms)上限没触发,或这次 shrink 没有任何 repaint 来喂 settleShrinkOnData |
| 拖拽 / 动画时 pty 收到一串中间尺寸 | scheduleFit 的 rAF 合并;以及 sentCols/sentRows 去重是否被绕过 |
| 变宽也出问题 | 变宽走的是 else 同拍分支,跟 pty 一起重折——问题多半在 proposeDimensions() 返回值或 host 布局,而非本 PR 的 shrink 逻辑 |
3心智模型补丁
读这个文件时,以前能做的三个假设现在得改:
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 行是三条新测试——占比约一半,行为被钉得比较实。
有兜底的(测试钉住的行为)
- 🟢 变宽同拍重折:
forwards a grow to the pty and reflows xterm in the same tick—— 断言 grow 时 pty 收到 resize 且 xterm 立刻重折。 - 🟢 变窄押后重折:
holds a shrink reflow off xterm until the pty repaint settles—— 断言 shrink 时 xterm 不当场重折,要等重绘 settle。 - 🟢 尺寸没动就丢弃:
drops a redelivered layout change whose dimensions did not move—— 断言重复投递的同尺寸事件不再发 pty resize。
薄冰(无测试直接兜底,验收 / 复发时留意)
- 🟡 时间常数是经验值:40ms「安静窗口」、250ms 上限没有针对慢链路(远程 daemon、高负载)的自适应;测试用假定时器验时序,不验这两个绝对值在真实延迟下够不够。真机 loopback 已自测通过,跨机 / 远程未覆盖。
- 🟡 「变窄」判据依赖 cols 单调:只比
dims.cols < terminal.cols。若某次布局同时缩宽又增高,走的是非 shrink 分支——本 PR 只针对宽度收缩这一类幽灵,行高变化不在此列(也不产生该幽灵)。 - ⚪ 依赖 prompt 用 DECSC/DECRC 且重绘可 settle:机制针对 powerlevel10k 这类「存取光标 + 一次性重绘」的 prompt。异形 prompt(持续吐字节、或不用 saved cursor)不在验证范围,但也不是本 PR 制造的风险。
6覆盖声明
本 PR 仅 2 文件、+167/−10,属「小 PR」,未分发 subagent,全量逐行精读。报告中每段代码均取自亲自 Read 过的 TerminalPanel.tsx(fix 分支)与其 main 侧旧版、及 TerminalPanel.test.tsx,非二手转述。测试小节只读了三条新测试的名称与意图,未逐行精读其 setup。daemon 侧 pty.resize 链路为既有代码、本 PR 未改,仅作为背景引用未展开。