Skip to content

Commit 3f0fb21

Browse files
Mayank-Mauryaclaudezkochan
authored
fix(default-reporter): erase trailing characters on progress line (#12351)
External processes like SSH passphrase prompts can write to the terminal between progress updates. The previous renderer used `ansi-diff`, which only overwrites the characters it knows changed, so leftover characters from the external output stayed visible on the progress line — e.g. `added 0sa':`, where `sa':` is a fragment of `Enter passphrase for key '.../.ssh/id_rsa':`. Closes #12350 ## Summary The interactive (non-append-only) reporter now redraws the whole frame in place on each update instead of incrementally diffing it: - return the cursor to the top-left of the previous frame (`ESC[<rows>A` followed by a carriage return, so the redraw starts at column 0 even if an external process left the cursor mid-line), - erase from there to the end of the display (`ESC[0J`), - reprint the frame — all in a single atomic write, so there is no flicker. Because the whole region is erased on every frame, any characters an external process wrote in between are cleared. This matches pacquet's `Output::Frame` rendering (the column-reset hardening was applied to both stacks). The now-unused `ansi-diff` dependency has been removed. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Zoltan Kochan <z@kochan.io>
1 parent 3b54d79 commit 3f0fb21

9 files changed

Lines changed: 45 additions & 21 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/cli.default-reporter": patch
3+
"pnpm": patch
4+
---
5+
6+
Fixed the progress line showing leftover characters from external processes that write to the terminal between progress updates (e.g. an SSH passphrase prompt would leave a fragment like `added 0sa':`). The interactive reporter now redraws each frame in place, erasing to the end of the display before reprinting, so any such remnants are cleared [#12350](https://github.com/pnpm/pnpm/issues/12350).

cli/default-reporter/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@
4646
"@pnpm/installing.dedupe.types": "workspace:*",
4747
"@pnpm/types": "workspace:*",
4848
"@pnpm/util.lex-comparator": "catalog:",
49-
"ansi-diff": "catalog:",
5049
"boxen": "catalog:",
5150
"chalk": "catalog:",
5251
"cli-truncate": "catalog:",

cli/default-reporter/src/index.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Config, ConfigContext } from '@pnpm/config.reader'
22
import type * as logs from '@pnpm/core-loggers'
33
import type { LogLevel, StreamParser } from '@pnpm/logger'
4-
import createDiffer from 'ansi-diff'
54
import * as Rx from 'rxjs'
65
import { filter, map, mergeAll } from 'rxjs/operators'
76

@@ -13,6 +12,12 @@ import { formatWarn } from './reporterForClient/utils/formatWarn.js'
1312

1413
export { formatWarn }
1514

15+
// ANSI "erase from cursor to end of display". Emitted before reprinting each
16+
// frame so that anything an external process (e.g. an SSH passphrase prompt)
17+
// wrote to the terminal between progress updates is cleared instead of bleeding
18+
// into the progress output.
19+
const ERASE_TO_END_OF_DISPLAY = '\x1b[0J'
20+
1621
export function initDefaultReporter (
1722
opts: {
1823
useStderr?: boolean
@@ -65,10 +70,6 @@ export function initDefaultReporter (
6570
subscription.unsubscribe()
6671
}
6772
}
68-
const diff = createDiffer({
69-
height: proc.stdout.rows,
70-
outputMaxWidth,
71-
})
7273
const subscription = output$
7374
.subscribe({
7475
complete () {}, // eslint-disable-line:no-empty
@@ -80,18 +81,39 @@ export function initDefaultReporter (
8081
const write = opts.useStderr
8182
? proc.stderr.write.bind(proc.stderr)
8283
: proc.stdout.write.bind(proc.stdout)
84+
let prevRows = 0
8385
function logUpdate (view: string) {
8486
// A new line should always be appended in case a prompt needs to appear.
8587
// Without a new line the prompt will be joined with the previous output.
8688
// An example of such prompt may be seen by running: pnpm update --interactive
8789
if (!view.endsWith(EOL)) view += EOL
88-
write(diff.update(view))
90+
// Redraw the whole frame in place: return the cursor to the top-left of the
91+
// previous frame, erase everything below it, then reprint. The `\r` resets
92+
// the column to 0 (cursor-up alone keeps the column) so the redraw starts
93+
// cleanly even when an external process left the cursor mid-line. Doing it
94+
// in a single write keeps the redraw atomic (no flicker) and clears any
95+
// characters an external process wrote in between.
96+
const moveToFrameTop = prevRows > 0 ? `\x1b[${prevRows}A\r` : '\r'
97+
write(`${moveToFrameTop}${ERASE_TO_END_OF_DISPLAY}${view}`)
98+
prevRows = countRows(view)
8999
}
90100
return () => {
91101
subscription.unsubscribe()
92102
}
93103
}
94104

105+
// Number of terminal rows a frame occupies. The frame always ends with a
106+
// newline, so this also equals how far below the frame's top the cursor rests
107+
// after printing it. Lines are assumed not to soft-wrap, matching how the
108+
// progress output is width-constrained before it reaches here.
109+
function countRows (frame: string): number {
110+
let rows = 0
111+
for (let i = 0; i < frame.length; i++) {
112+
if (frame.charCodeAt(i) === 10 /* \n */) rows++
113+
}
114+
return rows
115+
}
116+
95117
export function toOutput$ (
96118
opts: {
97119
streamParser: StreamParser<logs.Log>

cli/default-reporter/src/reporterForClient/reportLockfileVerification.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export function reportLockfileVerification (
2424
): Rx.Observable<Rx.Observable<{ msg: string }>> {
2525
const expectedDir = opts.workspaceDir ?? opts.cwd
2626
// A single inner observable so the `done` message overwrites the
27-
// transient `started` message in ansi-diff mode. In appendOnly mode
28-
// both lines are printed.
27+
// transient `started` message when the reporter redraws in place. In
28+
// appendOnly mode both lines are printed.
2929
return Rx.of(lockfileVerification$.pipe(
3030
map((log) => {
3131
const path_ = formatLockfilePath(log.lockfilePath, opts.cwd, expectedDir)

cli/default-reporter/test/cli.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ test('pnpm-render bin renders ndjson piped from stdin', async () => {
1616

1717
const stdout = await runBin(['install'], lines.map((line) => JSON.stringify(line)).join('\n') + '\n')
1818

19-
// ansi-diff intersperses cursor-movement escapes when the rendered string
20-
// changes (e.g. "1" → "2"), so we can't substring-match the final value
21-
// without terminal emulation. Verifying that any progress line rendered is
22-
// enough to prove the bin wired stdin → reporter correctly.
19+
// The reporter intersperses cursor-movement escapes when redrawing each
20+
// frame, so we can't substring-match the final value without terminal
21+
// emulation. Verifying that any progress line rendered is enough to prove
22+
// the bin wired stdin → reporter correctly.
2323
expect(stdout).toContain('Progress: resolved')
2424
})
2525

cli/default-reporter/test/reportingLockfileVerification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ test('prints lockfile verification in-progress and completion messages', async (
1919
})
2020

2121
// Subscribe before emitting so we capture both the started and the
22-
// done frame in ansi-diff mode.
22+
// done frame in the interactive (non-append-only) reporter.
2323
const frames = firstValueFrom(output$.pipe(take(2), toArray()))
2424

2525
const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')

pacquet/crates/default-reporter/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ impl Sink {
144144
if self.prev_rows > 0 {
145145
let _ = write!(buf, "\x1b[{}A", self.prev_rows);
146146
}
147+
// Reset the column to 0 (cursor-up alone keeps the column) so the
148+
// redraw starts cleanly even when an external process left the
149+
// cursor mid-line.
150+
buf.push('\r');
147151
buf.push_str("\x1b[0J");
148152
buf.push_str(&frame);
149153
buf.push('\n');

pnpm-lock.yaml

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ catalog:
159159
'@zkochan/table': ^2.0.1
160160
adm-zip: ^0.5.17
161161
amaro: ^1.1.9
162-
ansi-diff: ^1.2.0
163162
archy: ^1.0.0
164163
better-path-resolve: 2.0.0
165164
bin-links: ^6.0.2

0 commit comments

Comments
 (0)