From fa15b75d53fe3674ec064d6f9af1128b7fcec938 Mon Sep 17 00:00:00 2001 From: sangwook Date: Thu, 9 Oct 2025 10:48:10 +0900 Subject: [PATCH 1/2] test: add REPL multiline navigation test for viewport Add test for cursor position consistency when REPL multiline input exceeds terminal height. Tests navigation through a 27-line input on a 20-row terminal to ensure cursor moves correctly across viewport boundaries --- .../test-repl-multiline-navigation.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/test/parallel/test-repl-multiline-navigation.js b/test/parallel/test-repl-multiline-navigation.js index 3e5a40186b3062..fec28cefa262b2 100644 --- a/test/parallel/test-repl-multiline-navigation.js +++ b/test/parallel/test-repl-multiline-navigation.js @@ -251,3 +251,71 @@ tmpdir.refresh(); checkResults ); } + +{ + const historyPath = tmpdir.resolve(`.${Math.floor(Math.random() * 10000)}`); + + // Test for issue #59938: REPL cursor navigation in viewport overflow + // This test just verifies cursor position consistency when input exceeds + // terminal height. The actual screen rendering fix is harder to test + // but can be manually verified. + class MockOutput extends stream.Writable { + constructor() { + super({ + write(chunk, _, next) { + next(); + } + }); + this.columns = 80; + this.rows = 20; + } + } + + const checkResults = common.mustSucceed((r) => { + r.write('const arr = ['); + r.input.run([{ name: 'enter' }]); + + for (let i = 1; i <= 25; i++) { + r.write(` ${i},`); + r.input.run([{ name: 'enter' }]); + } + + r.write('];'); + + const endCursor = r.cursor; + assert.ok(endCursor > 0); + + for (let i = 0; i < 27; i++) { + r.input.run([{ name: 'up' }]); + } + + assert.strictEqual(r.cursor, 0); + + const prevRowsAtTop = r.prevRows; + assert.ok(prevRowsAtTop !== undefined); + + for (let i = 0; i < 10; i++) { + r.input.run([{ name: 'down' }]); + } + + const midCursor = r.cursor; + assert.ok(midCursor > 0 && midCursor < endCursor); + + // Navigate back to end + for (let i = 0; i < 17; i++) { + r.input.run([{ name: 'down' }]); + } + + assert.strictEqual(r.cursor, endCursor); + }); + + repl.createInternalRepl( + { NODE_REPL_HISTORY: historyPath }, + { + terminal: true, + input: new ActionStream(), + output: new MockOutput(), + }, + checkResults + ); +} From 315a79376a7e3605b926d1a7eb52a54276a90474 Mon Sep 17 00:00:00 2001 From: sangwook Date: Thu, 9 Oct 2025 11:11:52 +0900 Subject: [PATCH 2/2] repl: fix screen refresh in multiline viewport overflow When multiline REPL input exceeds the terminal height, navigating the cursor would move the cursor position correctly but fail to refresh the screen properly, leaving stale content visible. This implements viewport-based rendering in kRefreshLine() that displays only the visible portion of input centered around the cursor position when content exceeds terminal height. Fixes: https://github.com/nodejs/node/issues/59938 --- lib/internal/readline/interface.js | 45 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 46674883381392..2569911e5ebb42 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -16,6 +16,7 @@ const { MathFloor, MathMax, MathMaxApply, + MathMin, NumberIsFinite, ObjectDefineProperty, ObjectSetPrototypeOf, @@ -364,6 +365,11 @@ class Interface extends InterfaceConstructor { return Infinity; } + get rows() { + if (this.output?.rows) return this.output.rows; + return Infinity; + } + /** * Sets the prompt written to the output. * @param {string} prompt @@ -500,6 +506,10 @@ class Interface extends InterfaceConstructor { // cursor position const cursorPos = this.getCursorPos(); + const terminalRows = this.rows; + + const exceedsTerminal = lineRows >= terminalRows && terminalRows !== Infinity; + // First move to the bottom of the current line, based on cursor pos const prevRows = this.prevRows || 0; if (prevRows > 0) { @@ -513,16 +523,41 @@ class Interface extends InterfaceConstructor { if (this[kIsMultiline]) { const lines = StringPrototypeSplit(this.line, '\n'); - // Write first line with normal prompt - this[kWriteToOutput](this[kPrompt] + lines[0]); - // For continuation lines, add the "|" prefix - for (let i = 1; i < lines.length; i++) { + let startLine = 0; + let endLine = lines.length; + let visibleCursorRow = cursorPos.rows; + + if (exceedsTerminal) { + const maxVisibleLines = terminalRows - 2; + + const halfViewport = MathFloor(maxVisibleLines / 2); + + if (cursorPos.rows < halfViewport) { + startLine = 0; + endLine = MathMin(lines.length, maxVisibleLines); + } else if (cursorPos.rows >= lines.length - halfViewport) { + startLine = MathMax(0, lines.length - maxVisibleLines); + endLine = lines.length; + visibleCursorRow = cursorPos.rows - startLine; + } else { + startLine = cursorPos.rows - halfViewport; + endLine = MathMin(lines.length, startLine + maxVisibleLines); + visibleCursorRow = halfViewport; + } + } + + this[kWriteToOutput](this[kPrompt] + lines[startLine]); + + for (let i = startLine + 1; i < endLine; i++) { this[kWriteToOutput](`\n${kMultilinePrompt.description}` + lines[i]); } + + this.prevRows = exceedsTerminal ? visibleCursorRow : cursorPos.rows; } else { // Write the prompt and the current buffer content. this[kWriteToOutput](line); + this.prevRows = cursorPos.rows; } // Force terminal to allocate a new line @@ -537,8 +572,6 @@ class Interface extends InterfaceConstructor { if (diff > 0) { moveCursor(this.output, 0, -diff); } - - this.prevRows = cursorPos.rows; } /**