diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 08f7aaa9e3e7e8..cace4f03a9d3be 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -780,6 +780,9 @@ class Interface extends InterfaceConstructor { output += '\r\n\r\n'; } this[kWriteToOutput](output); + if (StringPrototypeIncludes(this[kPrompt], '\n') || this[kIsMultiline]) { + this.prevRows = 0; + } this[kRefreshLine](); } diff --git a/test/parallel/test-readline-tab-complete.js b/test/parallel/test-readline-tab-complete.js index 83b5da349d0d06..33c0761cb7a396 100644 --- a/test/parallel/test-readline-tab-complete.js +++ b/test/parallel/test-readline-tab-complete.js @@ -138,3 +138,180 @@ if (process.env.TERM === 'dumb') { })); })); } + +{ + class VirtualScreen { + constructor() { + this.rows = [[]]; + this.row = 0; + this.col = 0; + } + + ensureRow(row) { + while (this.rows.length <= row) this.rows.push([]); + } + + setChar(row, col, ch) { + this.ensureRow(row); + const target = this.rows[row]; + while (target.length <= col) target.push(' '); + target[col] = ch; + } + + clearLineRight() { + this.ensureRow(this.row); + const target = this.rows[this.row]; + if (this.col < target.length) { + target.length = this.col; + } + } + + clearFromCursor() { + this.clearLineRight(); + if (this.row + 1 < this.rows.length) { + this.rows.length = this.row + 1; + } + } + + moveCursor(dx, dy) { + this.row = Math.max(0, this.row + dy); + this.ensureRow(this.row); + this.col = Math.max(0, this.col + dx); + } + + handleEscape(params, code) { + switch (code) { + case 'A': // Cursor Up + this.moveCursor(0, -(Number(params) || 1)); + break; + case 'B': // Cursor Down + this.moveCursor(0, Number(params) || 1); + break; + case 'C': // Cursor Forward + this.moveCursor(Number(params) || 1, 0); + break; + case 'D': // Cursor Backward + this.moveCursor(-(Number(params) || 1), 0); + break; + case 'G': // Cursor Horizontal Absolute + this.col = Math.max(0, (Number(params) || 1) - 1); + break; + case 'H': + case 'f': { // Cursor Position + const [row, col] = params.split(';').map((n) => Number(n) || 1); + this.row = Math.max(0, row - 1); + this.col = Math.max(0, (col ?? 1) - 1); + this.ensureRow(this.row); + break; + } + case 'J': + this.clearFromCursor(); + break; + case 'K': + this.clearLineRight(); + break; + default: + break; + } + } + + write(chunk) { + for (let i = 0; i < chunk.length; i++) { + const ch = chunk[i]; + if (ch === '\r') { + this.col = 0; + continue; + } + if (ch === '\n') { + this.row++; + this.col = 0; + this.ensureRow(this.row); + continue; + } + if (ch === '\u001b' && chunk[i + 1] === '[') { + let j = i + 2; + let params = ''; + while (j < chunk.length) { + const code = chunk[j]; + if ((code >= '0' && code <= '9') || code === ';') { + params += code; + j++; + continue; + } + this.handleEscape(params, code); + i = j; + break; + } + continue; + } + this.setChar(this.row, this.col, ch); + this.col++; + } + } + + getLines() { + return this.rows.map((row) => row.join('').trimEnd()); + } + } + + class FakeTTY extends EventEmitter { + columns = 80; + rows = 24; + isTTY = true; + + constructor(screen) { + super(); + this.screen = screen; + } + + write(data) { + this.screen.write(data); + return true; + } + + resume() {} + + pause() {} + + end() {} + + setRawMode(mode) { + this.isRaw = mode; + } + } + + const screen = new VirtualScreen(); + const fi = new FakeTTY(screen); + + const rli = new readline.Interface({ + input: fi, + output: fi, + terminal: true, + completer: (line) => [['foobar', 'foobaz'], line], + }); + + const promptLines = ['multiline', 'prompt', 'eats', 'output', '> ']; + rli.setPrompt(promptLines.join('\n')); + rli.prompt(); + + ['f', 'o', 'o', '\t', '\t'].forEach((ch) => fi.emit('data', ch)); + + const display = screen.getLines(); + + assert.strictEqual(display[0], 'multiline'); + assert.strictEqual(display[1], 'prompt'); + assert.strictEqual(display[2], 'eats'); + assert.strictEqual(display[3], 'output'); + + const inputLineIndex = 4; + assert.ok( + display[inputLineIndex].includes('> fooba'), + 'prompt line should keep completed input', + ); + + const completionLineExists = + display.some((l) => l.includes('foobar') && l.includes('foobaz')); + assert.ok(completionLineExists, 'completion list should be visible'); + + rli.close(); +}