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; } /** 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 + ); +}