Skip to content

Commit 5337518

Browse files
committed
fix: account for visual line wrapping when erasing terminal output on resize
When the terminal is resized narrower, previously rendered lines soft-wrap to occupy more visual rows than the logical line count tracks. This causes eraseLines to under-erase, leaving ghost/stale lines at the top of the output. The fix introduces visual row counting that uses stringWidth to measure each line's visible width and Math.ceil(width / columns) to compute the actual terminal rows occupied after wrapping. This is applied in three places: - The standard render path in log-update now computes visual rows from previousOutput at the current terminal width when erasing - A new clearWithWrapping() method on LogUpdate uses the same calculation for resize-triggered clears - The resize handler in Ink flushes pending throttled writes before clearing, ensuring previousOutput reflects what's actually on screen Fixes vadimdemedes#907
1 parent d856a6c commit 5337518

2 files changed

Lines changed: 89 additions & 9 deletions

File tree

src/ink.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,18 @@ export default class Ink {
454454
const currentWidth = getWindowSize(this.options.stdout).columns;
455455

456456
if (currentWidth < this.lastTerminalWidth) {
457-
// We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
458-
this.log.clear();
457+
// Flush any pending throttled writes so the log's previousOutput
458+
// reflects what's actually on screen before we clear.
459+
if ('flush' in this.throttledLog) {
460+
(this.throttledLog as DebouncedFunc<(output: string) => void>).flush();
461+
}
462+
463+
// Use clearWithWrapping to account for terminal soft-wrapping:
464+
// lines rendered at the old width now occupy more visual rows at the
465+
// new narrower width. The log instance uses its own previousOutput
466+
// (which reflects what was actually written to the stream) to
467+
// calculate the correct number of rows to erase.
468+
this.log.clearWithWrapping();
459469
this.lastOutput = '';
460470
this.lastOutputToRender = '';
461471
}

src/log-update.ts

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {type Writable} from 'node:stream';
22
import ansiEscapes from 'ansi-escapes';
33
import cliCursor from 'cli-cursor';
4+
import stringWidth from 'string-width';
45
import {
56
type CursorPosition,
67
cursorPositionChanged,
@@ -14,6 +15,7 @@ export type {CursorPosition} from './cursor-helpers.js';
1415

1516
export type LogUpdate = {
1617
clear: () => void;
18+
clearWithWrapping: () => void;
1719
done: () => void;
1820
reset: () => void;
1921
sync: (str: string) => void;
@@ -28,6 +30,31 @@ export type LogUpdate = {
2830
const visibleLineCount = (lines: string[], str: string): number =>
2931
str.endsWith('\n') ? lines.length - 1 : lines.length;
3032

33+
// Count the visual rows a set of lines occupies in the terminal,
34+
// accounting for soft-wrapping when a line's visible width exceeds
35+
// the terminal's column count.
36+
const visualRowCount = (lines: string[], columns: number): number => {
37+
if (columns <= 0) {
38+
return lines.length;
39+
}
40+
41+
let rows = 0;
42+
43+
for (const line of lines) {
44+
const width = stringWidth(line);
45+
rows += Math.max(1, Math.ceil(width / columns));
46+
}
47+
48+
return rows;
49+
};
50+
51+
// Safely read terminal column count from the stream.
52+
const getColumns = (stream: Writable): number =>
53+
'columns' in stream &&
54+
typeof (stream as NodeJS.WriteStream).columns === 'number'
55+
? (stream as NodeJS.WriteStream).columns
56+
: 0;
57+
3158
const createStandard = (
3259
stream: Writable,
3360
{showCursor = false} = {},
@@ -86,17 +113,19 @@ const createStandard = (
86113
}),
87114
);
88115
} else {
116+
const columns = getColumns(stream);
117+
const rowsToErase =
118+
columns > 0 && previousLineCount > 0
119+
? visualRowCount(previousOutput.split('\n'), columns)
120+
: previousLineCount;
89121
previousOutput = str;
90122
const returnPrefix = buildReturnToBottomPrefix(
91123
cursorWasShown,
92-
previousLineCount,
124+
rowsToErase,
93125
previousCursorPosition,
94126
);
95127
stream.write(
96-
returnPrefix +
97-
ansiEscapes.eraseLines(previousLineCount) +
98-
str +
99-
cursorSuffix,
128+
returnPrefix + ansiEscapes.eraseLines(rowsToErase) + str + cursorSuffix,
100129
);
101130
previousLineCount = lines.length;
102131
}
@@ -119,6 +148,24 @@ const createStandard = (
119148
cursorWasShown = false;
120149
};
121150

151+
render.clearWithWrapping = () => {
152+
const columns = getColumns(stream);
153+
const rowsToErase =
154+
columns > 0 && previousLineCount > 0
155+
? visualRowCount(previousOutput.split('\n'), columns)
156+
: previousLineCount;
157+
const prefix = buildReturnToBottomPrefix(
158+
cursorWasShown,
159+
rowsToErase,
160+
previousCursorPosition,
161+
);
162+
stream.write(prefix + ansiEscapes.eraseLines(rowsToErase));
163+
previousOutput = '';
164+
previousLineCount = 0;
165+
previousCursorPosition = undefined;
166+
cursorWasShown = false;
167+
};
168+
122169
render.done = () => {
123170
previousOutput = '';
124171
previousLineCount = 0;
@@ -233,17 +280,22 @@ const createIncremental = (
233280
return true;
234281
}
235282

283+
const columns = getColumns(stream);
284+
const previousRowCount =
285+
columns > 0 && previousLines.length > 0
286+
? visualRowCount(previousLines, columns)
287+
: previousLines.length;
236288
const returnPrefix = buildReturnToBottomPrefix(
237289
cursorWasShown,
238-
previousLines.length,
290+
previousRowCount,
239291
previousCursorPosition,
240292
);
241293

242294
if (str === '\n' || previousOutput.length === 0) {
243295
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
244296
stream.write(
245297
returnPrefix +
246-
ansiEscapes.eraseLines(previousLines.length) +
298+
ansiEscapes.eraseLines(previousRowCount) +
247299
str +
248300
cursorSuffix,
249301
);
@@ -322,6 +374,24 @@ const createIncremental = (
322374
cursorWasShown = false;
323375
};
324376

377+
render.clearWithWrapping = () => {
378+
const columns = getColumns(stream);
379+
const rowsToErase =
380+
columns > 0 && previousLines.length > 0
381+
? visualRowCount(previousLines, columns)
382+
: previousLines.length;
383+
const prefix = buildReturnToBottomPrefix(
384+
cursorWasShown,
385+
rowsToErase,
386+
previousCursorPosition,
387+
);
388+
stream.write(prefix + ansiEscapes.eraseLines(rowsToErase));
389+
previousOutput = '';
390+
previousLines = [];
391+
previousCursorPosition = undefined;
392+
cursorWasShown = false;
393+
};
394+
325395
render.done = () => {
326396
previousOutput = '';
327397
previousLines = [];

0 commit comments

Comments
 (0)