From 15b89e1ae286123932a24c441302b90682b5c56e Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 7 Dec 2025 11:28:51 +0900 Subject: [PATCH 1/3] test: add multiline prompt completion regression for readline --- test/parallel/test-readline-tab-complete.js | 169 ++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/test/parallel/test-readline-tab-complete.js b/test/parallel/test-readline-tab-complete.js index 83b5da349d0d06..b9e051945d850a 100644 --- a/test/parallel/test-readline-tab-complete.js +++ b/test/parallel/test-readline-tab-complete.js @@ -138,3 +138,172 @@ 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] === '[') { + const match = /^\u001b\[([0-9;]*)([A-Za-z])/.exec(chunk.slice(i)); + if (match) { + this.handleEscape(match[1], match[2]); + i += match[0].length - 1; + 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(); +} From 35c1d6b264b9f69b8b579d07250e4ca47d0e6a31 Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 7 Dec 2025 12:25:07 +0900 Subject: [PATCH 2/3] readline: keep tab completions visible with multiline prompts --- lib/internal/readline/interface.js | 3 +++ 1 file changed, 3 insertions(+) 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](); } From e81bf43572ec3afb80cae2fc0c2ef8dc2d0231d7 Mon Sep 17 00:00:00 2001 From: sangwook Date: Sun, 7 Dec 2025 12:32:53 +0900 Subject: [PATCH 3/3] test: fix lint issues in readline tab completion regression --- test/parallel/test-readline-tab-complete.js | 340 ++++++++++---------- 1 file changed, 174 insertions(+), 166 deletions(-) diff --git a/test/parallel/test-readline-tab-complete.js b/test/parallel/test-readline-tab-complete.js index b9e051945d850a..33c0761cb7a396 100644 --- a/test/parallel/test-readline-tab-complete.js +++ b/test/parallel/test-readline-tab-complete.js @@ -140,170 +140,178 @@ 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] === '[') { - const match = /^\u001b\[([0-9;]*)([A-Za-z])/.exec(chunk.slice(i)); - if (match) { - this.handleEscape(match[1], match[2]); - i += match[0].length - 1; - 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(); + 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(); }