Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/cursor-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,16 @@ Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last
export const buildCursorSuffix = (
visibleLineCount: number,
cursorPosition: CursorPosition | undefined,
viewportHeight = Infinity,
): string => {
if (!cursorPosition) {
return '';
}

const moveUp = visibleLineCount - cursorPosition.y;
const moveUp = Math.min(
visibleLineCount - cursorPosition.y,
viewportHeight,
);
return (
(moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : '') +
ansiEscapes.cursorTo(cursorPosition.x) +
Expand Down Expand Up @@ -64,6 +68,7 @@ export type CursorOnlyInput = {
previousCursorPosition: CursorPosition | undefined;
visibleLineCount: number;
cursorPosition: CursorPosition | undefined;
viewportHeight?: number;
};

/**
Expand All @@ -79,6 +84,7 @@ export const buildCursorOnlySequence = (input: CursorOnlyInput): string => {
const cursorSuffix = buildCursorSuffix(
input.visibleLineCount,
input.cursorPosition,
input.viewportHeight,
);
return hidePrefix + returnToBottom + cursorSuffix;
};
Expand Down
40 changes: 28 additions & 12 deletions src/log-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ export type LogUpdate = {
const visibleLineCount = (lines: string[], str: string): number =>
str.endsWith('\n') ? lines.length - 1 : lines.length;

// Get the viewport height from a stream. TTY streams expose `.rows`;
// non-TTY streams don't, so we fall back to Infinity (no clamping).
const getViewportRows = (stream: Writable): number =>
(stream as NodeJS.WriteStream).rows || Infinity;

// Clamp a line count so that eraseLines / cursorUp never move the cursor
// above the visible viewport. Lines beyond the viewport have already
// scrolled into terminal scrollback and cannot be erased.
const clampToViewport = (lineCount: number, stream: Writable): number =>
Math.min(lineCount, getViewportRows(stream));

const createStandard = (
stream: Writable,
{showCursor = false} = {},
Expand Down Expand Up @@ -73,7 +84,8 @@ const createStandard = (

const lines = str.split('\n');
const visibleCount = visibleLineCount(lines, str);
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
const viewportRows = getViewportRows(stream);
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, viewportRows);

if (str === previousOutput && cursorChanged) {
stream.write(
Expand All @@ -83,6 +95,7 @@ const createStandard = (
previousCursorPosition,
visibleLineCount: visibleCount,
cursorPosition: activeCursor,
viewportHeight: viewportRows,
}),
);
} else {
Expand All @@ -94,7 +107,7 @@ const createStandard = (
);
stream.write(
returnPrefix +
ansiEscapes.eraseLines(previousLineCount) +
ansiEscapes.eraseLines(clampToViewport(previousLineCount, stream)) +
str +
cursorSuffix,
);
Expand All @@ -112,7 +125,7 @@ const createStandard = (
previousLineCount,
previousCursorPosition,
);
stream.write(prefix + ansiEscapes.eraseLines(previousLineCount));
stream.write(prefix + ansiEscapes.eraseLines(clampToViewport(previousLineCount, stream)));
previousOutput = '';
previousLineCount = 0;
previousCursorPosition = undefined;
Expand Down Expand Up @@ -152,7 +165,7 @@ const createStandard = (

if (activeCursor) {
stream.write(
buildCursorSuffix(visibleLineCount(lines, str), activeCursor),
buildCursorSuffix(visibleLineCount(lines, str), activeCursor, getViewportRows(stream)),
);
}

Expand Down Expand Up @@ -218,6 +231,8 @@ const createIncremental = (
const visibleCount = visibleLineCount(nextLines, str);
const previousVisible = visibleLineCount(previousLines, previousOutput);

const viewportRows = getViewportRows(stream);

if (str === previousOutput && cursorChanged) {
stream.write(
buildCursorOnlySequence({
Expand All @@ -226,6 +241,7 @@ const createIncremental = (
previousCursorPosition,
visibleLineCount: visibleCount,
cursorPosition: activeCursor,
viewportHeight: viewportRows,
}),
);
previousCursorPosition = activeCursor ? {...activeCursor} : undefined;
Expand All @@ -240,10 +256,10 @@ const createIncremental = (
);

if (str === '\n' || previousOutput.length === 0) {
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, viewportRows);
stream.write(
returnPrefix +
ansiEscapes.eraseLines(previousLines.length) +
ansiEscapes.eraseLines(clampToViewport(previousLines.length, stream)) +
str +
cursorSuffix,
);
Expand All @@ -266,11 +282,11 @@ const createIncremental = (
const previousHadTrailingNewline = previousOutput.endsWith('\n');
const extraSlot = previousHadTrailingNewline ? 1 : 0;
buffer.push(
ansiEscapes.eraseLines(previousVisible - visibleCount + extraSlot),
ansiEscapes.cursorUp(visibleCount),
ansiEscapes.eraseLines(Math.min(previousVisible - visibleCount + extraSlot, viewportRows)),
ansiEscapes.cursorUp(Math.min(visibleCount, viewportRows - 1)),
);
} else {
buffer.push(ansiEscapes.cursorUp(previousLines.length - 1));
buffer.push(ansiEscapes.cursorUp(Math.min(previousLines.length - 1, viewportRows - 1)));
}

for (let i = 0; i < visibleCount; i++) {
Expand All @@ -297,7 +313,7 @@ const createIncremental = (
);
}

const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, viewportRows);
buffer.push(cursorSuffix);

stream.write(buffer.join(''));
Expand All @@ -315,7 +331,7 @@ const createIncremental = (
previousLines.length,
previousCursorPosition,
);
stream.write(prefix + ansiEscapes.eraseLines(previousLines.length));
stream.write(prefix + ansiEscapes.eraseLines(clampToViewport(previousLines.length, stream)));
previousOutput = '';
previousLines = [];
previousCursorPosition = undefined;
Expand Down Expand Up @@ -355,7 +371,7 @@ const createIncremental = (

if (activeCursor) {
stream.write(
buildCursorSuffix(visibleLineCount(lines, str), activeCursor),
buildCursorSuffix(visibleLineCount(lines, str), activeCursor, getViewportRows(stream)),
);
}

Expand Down
5 changes: 4 additions & 1 deletion test/helpers/create-stdout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ export type FakeStdout = {
getWrites: () => string[];
} & NodeJS.WriteStream;

const createStdout = (columns?: number, isTTY?: boolean): FakeStdout => {
const createStdout = (columns?: number, isTTY?: boolean, rows?: number): FakeStdout => {
const stdout = new EventEmitter() as unknown as FakeStdout;
stdout.columns = columns ?? 100;
stdout.isTTY = isTTY ?? true;
if (rows !== undefined) {
stdout.rows = rows;
}

const write = spy();
stdout.write = write;
Expand Down
133 changes: 133 additions & 0 deletions test/log-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,3 +621,136 @@ test('incremental rendering - render to empty string (full clear vs early exit)'
render('\n');
t.is((stdout.write as any).callCount, 2); // No additional write
});

// Viewport clamping tests — cursor-up must never exceed stream.rows

test('standard rendering - clamps eraseLines to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {showCursor: true});

// Render 20 lines (far exceeds viewport of 5)
const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);

// Update to different content
render('New content\n');

const secondCall = (stdout.write as any).secondCall.args[0] as string;
// eraseLines should be clamped to 5 (viewport), not 21 (actual line count)
t.true(secondCall.includes(ansiEscapes.eraseLines(5)));
t.false(secondCall.includes(ansiEscapes.eraseLines(21)));
});

test('standard rendering - clear() clamps eraseLines to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {showCursor: true});

const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);
render.clear();

const clearCall = (stdout.write as any).secondCall.args[0] as string;
t.true(clearCall.includes(ansiEscapes.eraseLines(5)));
t.false(clearCall.includes(ansiEscapes.eraseLines(21)));
});

test('incremental rendering - clamps cursorUp to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {
showCursor: true,
incremental: true,
});

// Render 20 lines
const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);

// Update one line — triggers incremental path with cursorUp
const updatedLines = Array.from({length: 20}, (_, i) =>
i === 10 ? 'Updated' : `Line ${i + 1}`,
).join('\n') + '\n';
render(updatedLines);

const secondCall = (stdout.write as any).secondCall.args[0] as string;
// cursorUp should be clamped to viewport - 1 = 4, not 20 (lines.length - 1)
t.true(secondCall.includes(ansiEscapes.cursorUp(4)));
t.false(secondCall.includes(ansiEscapes.cursorUp(20)));
});

test('incremental rendering - clear() clamps eraseLines to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {
showCursor: true,
incremental: true,
});

const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);
render.clear();

const clearCall = (stdout.write as any).secondCall.args[0] as string;
t.true(clearCall.includes(ansiEscapes.eraseLines(5)));
t.false(clearCall.includes(ansiEscapes.eraseLines(21)));
});

test('standard rendering - no clamping when content fits viewport', t => {
const stdout = createStdout(100, true, 30);
const render = logUpdate.create(stdout, {showCursor: true});

// 3 lines fits easily in 30-row viewport
render('Line 1\nLine 2\nLine 3\n');
render('Updated\n');

const secondCall = (stdout.write as any).secondCall.args[0] as string;
// eraseLines(4) is fine — under viewport limit of 30
t.true(secondCall.includes(ansiEscapes.eraseLines(4)));
});

test('incremental rendering - no clamping when content fits viewport', t => {
const stdout = createStdout(100, true, 30);
const render = logUpdate.create(stdout, {
showCursor: true,
incremental: true,
});

render('Line 1\nLine 2\nLine 3\n');
render('Line 1\nUpdated\nLine 3\n');

const secondCall = (stdout.write as any).secondCall.args[0] as string;
// cursorUp(3) is fine — under viewport limit of 30
t.true(secondCall.includes(ansiEscapes.cursorUp(3)));
});

test('standard rendering - clamps cursorUp in buildCursorSuffix to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {showCursor: true});

// Position cursor at line 0 with 20 visible lines → moveUp = 20
// Should be clamped to viewport height (5)
render.setCursorPosition({x: 0, y: 0});

const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);

const firstCall = (stdout.write as any).firstCall.args[0] as string;
// cursorUp should be clamped to 5 (viewport), not 20 (visibleLineCount - y)
t.true(firstCall.includes(ansiEscapes.cursorUp(5)));
t.false(firstCall.includes(ansiEscapes.cursorUp(20)));
});

test('incremental rendering - clamps cursorUp in buildCursorSuffix to viewport height', t => {
const stdout = createStdout(100, true, 5);
const render = logUpdate.create(stdout, {
showCursor: true,
incremental: true,
});

render.setCursorPosition({x: 0, y: 0});

const lines = Array.from({length: 20}, (_, i) => `Line ${i + 1}`).join('\n') + '\n';
render(lines);

const firstCall = (stdout.write as any).firstCall.args[0] as string;
t.true(firstCall.includes(ansiEscapes.cursorUp(5)));
t.false(firstCall.includes(ansiEscapes.cursorUp(20)));
});