Summary
Since v11.8.0, running multiple long-running scripts with pnpm run --parallel (e.g. pnpm -r --parallel dev) in an interactive terminal causes the reporter to continuously repaint the entire screen and never settle — a relentless flicker/scroll that makes the output unusable. The underlying processes start exactly once and run fine; only the reporter is broken.
It is a regression introduced by #12351 / commit 3f0fb21 ("erase trailing characters on progress line"), which replaced the height-aware ansi-diff renderer with a manual in-place redraw whose cursor-up arithmetic is not clamped to the terminal height.
- 11.5.2 / 11.7.0: fine.
- 11.8.0: flickers / never settles.
- Workaround:
--reporter=append-only (sidesteps the interactive renderer entirely).
Environment
- pnpm: 11.8.0 (broken) vs 11.5.2 (fine) — same machine, same repo, same terminal
- Node.js: 24.13.0
- OS: macOS 15 (Apple Silicon)
- Terminal: Ghostty (
TERM=xterm-ghostty); reproduces in any interactive TTY whose height is smaller than the rendered frame
- Workspace: 8 packages, run via
pnpm -r --parallel dev (each script is long-running and verbose on startup)
Steps to reproduce
- A workspace with several packages, each with a long-running, verbose
dev script. Minimal version — root pnpm-workspace.yaml listing packages/*, and ~6 packages each with:
{ "scripts": { "dev": "node -e \"for(let i=0;i<40;i++)console.log('pkg line '+i); setInterval(()=>{}, 1e9)\"" } }
The trigger is simply that the combined live frame the reporter draws is taller than the terminal window; many parallel projects + multi-line startup output is the common real-world case.
- In an interactive terminal (e.g. 24–50 rows), run:
Expected
The reporter renders the live multi-line view and settles once the processes are up (as in 11.5.2 / 11.7.0).
Actual
The reporter repaints the whole screen on every frame indefinitely — the startup summary (Scope: N of M workspace projects + each pkg dev$ … line + each process's buffered output) is reprinted thousands of times and the terminal scrolls/flickers without ever settling.
Evidence
Captured the same run through a pipe (no TTY → append-only fallback) vs a pty (script, interactive reporter):
| marker (printed once per process) |
piped (real exec count) |
pty / interactive |
| process banner |
4 |
11,123 |
uv sync "Checked …" |
1 |
2,815 |
| server "startup complete" |
3 |
1,794 |
Same single execution; the interactive reporter re-emits each buffered frame thousands of times. The emitted ESC[<N>A cursor-up value grows without bound across frames (observed climbing 2659 → 2823), i.e. it tracks cumulative lines printed rather than a value bounded by the terminal height.
Root cause
cli/default-reporter/src/index.ts, changed in #12351 / 3f0fb21:
Before (height-aware):
const diff = createDiffer({ height: proc.stdout.rows, outputMaxWidth })
// ...
write(diff.update(view))
ansi-diff was constructed with height: proc.stdout.rows, so its redraw was clamped to the visible viewport.
After (not height-aware):
let prevRows = 0
function logUpdate (view) {
if (!view.endsWith(EOL)) view += EOL
const moveToFrameTop = prevRows > 0 ? `\x1b[${prevRows}A\r` : '\r'
write(`${moveToFrameTop}${ERASE_TO_END_OF_DISPLAY}${view}`)
prevRows = countRows(view) // counts '\n' only
}
// countRows: "Lines are assumed not to soft-wrap"
Two problems, both because prevRows no longer relates to the terminal height:
- Frame taller than the terminal. Once the frame has more lines than
proc.stdout.rows, printing it scrolls the viewport, so the top of the logical frame moves into scrollback. The next frame emits ESC[<prevRows>A, but a terminal cannot move the cursor up past the top of the viewport into scrollback — so it lands at the top of the screen, not the true frame top. ESC[0J then erases the visible region and the oversized frame is reprinted, scrolling again. Every frame rewrites the whole screen and prevRows keeps growing → permanent flicker that never converges.
- Soft-wrapping.
countRows counts \n only and explicitly assumes no soft-wrap, but run --parallel interleaves long child-output lines that wrap. prevRows then undercounts physical rows, so even sub-screen frames can misplace the cursor.
The old ansi-diff differ avoided both by being constructed with the terminal height.
Suggested fix
Restore height-awareness in the in-place redraw. Options:
- Clamp the redraw to the viewport: cap
prevRows (and the cursor-up move) at proc.stdout.rows, and erase/reprint only the visible region — matching what ansi-diff did via height.
- When the frame would exceed
proc.stdout.rows, stop the in-place redraw and append (or render only the last rows - 1 lines) instead of emitting an out-of-range ESC[<N>A.
- Account for soft-wrap when computing rows (use
outputMaxWidth / cli-truncate width math, not a raw \n count).
The #12350 fix (erasing external-process remnants) can be preserved while clamping the cursor math to the terminal height.
Workaround
pnpm -r --parallel --reporter=append-only dev — disables the interactive renderer; output streams cleanly regardless of frame/terminal height.
Summary
Since v11.8.0, running multiple long-running scripts with
pnpm run --parallel(e.g.pnpm -r --parallel dev) in an interactive terminal causes the reporter to continuously repaint the entire screen and never settle — a relentless flicker/scroll that makes the output unusable. The underlying processes start exactly once and run fine; only the reporter is broken.It is a regression introduced by #12351 / commit
3f0fb21("erase trailing characters on progress line"), which replaced the height-awareansi-diffrenderer with a manual in-place redraw whose cursor-up arithmetic is not clamped to the terminal height.--reporter=append-only(sidesteps the interactive renderer entirely).Environment
TERM=xterm-ghostty); reproduces in any interactive TTY whose height is smaller than the rendered framepnpm -r --parallel dev(each script is long-running and verbose on startup)Steps to reproduce
devscript. Minimal version — rootpnpm-workspace.yamllistingpackages/*, and ~6 packages each with:{ "scripts": { "dev": "node -e \"for(let i=0;i<40;i++)console.log('pkg line '+i); setInterval(()=>{}, 1e9)\"" } }Expected
The reporter renders the live multi-line view and settles once the processes are up (as in 11.5.2 / 11.7.0).
Actual
The reporter repaints the whole screen on every frame indefinitely — the startup summary (
Scope: N of M workspace projects+ eachpkg dev$ …line + each process's buffered output) is reprinted thousands of times and the terminal scrolls/flickers without ever settling.Evidence
Captured the same run through a pipe (no TTY → append-only fallback) vs a pty (
script, interactive reporter):uv sync"Checked …"Same single execution; the interactive reporter re-emits each buffered frame thousands of times. The emitted
ESC[<N>Acursor-up value grows without bound across frames (observed climbing 2659 → 2823), i.e. it tracks cumulative lines printed rather than a value bounded by the terminal height.Root cause
cli/default-reporter/src/index.ts, changed in #12351 /3f0fb21:Before (height-aware):
ansi-diffwas constructed withheight: proc.stdout.rows, so its redraw was clamped to the visible viewport.After (not height-aware):
Two problems, both because
prevRowsno longer relates to the terminal height:proc.stdout.rows, printing it scrolls the viewport, so the top of the logical frame moves into scrollback. The next frame emitsESC[<prevRows>A, but a terminal cannot move the cursor up past the top of the viewport into scrollback — so it lands at the top of the screen, not the true frame top.ESC[0Jthen erases the visible region and the oversized frame is reprinted, scrolling again. Every frame rewrites the whole screen andprevRowskeeps growing → permanent flicker that never converges.countRowscounts\nonly and explicitly assumes no soft-wrap, butrun --parallelinterleaves long child-output lines that wrap.prevRowsthen undercounts physical rows, so even sub-screen frames can misplace the cursor.The old
ansi-diffdiffer avoided both by being constructed with the terminalheight.Suggested fix
Restore height-awareness in the in-place redraw. Options:
prevRows(and the cursor-up move) atproc.stdout.rows, and erase/reprint only the visible region — matching whatansi-diffdid viaheight.proc.stdout.rows, stop the in-place redraw and append (or render only the lastrows - 1lines) instead of emitting an out-of-rangeESC[<N>A.outputMaxWidth/cli-truncatewidth math, not a raw\ncount).The #12350 fix (erasing external-process remnants) can be preserved while clamping the cursor math to the terminal height.
Workaround
pnpm -r --parallel --reporter=append-only dev— disables the interactive renderer; output streams cleanly regardless of frame/terminal height.