From 4b79ce39677a2cec56fe7ff73aab5849aebceba5 Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Thu, 25 Jun 2026 19:13:26 +0900 Subject: [PATCH] readline: fix remembered column in multiline When moving the cursor vertically past a line too short to hold the current column, the column is remembered and restored on a later, longer line. The remembered column is a visual column that includes the continuation prompt width, but it was compared against the raw target line length, so columns in the (length, length + promptLen) range wrongly clamped to the end of the line instead of being restored. Signed-off-by: Daijiro Wachi --- lib/internal/readline/interface.js | 6 +- ...st-readline-multiline-remembered-column.js | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-readline-multiline-remembered-column.js diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 08f7aaa9e3e7e8..78c25361625111 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -1115,7 +1115,11 @@ class Interface extends InterfaceConstructor { amountToMove = -adj.length - 1; } if (this[kPreviousCursorCols] !== -1) { - if (this[kPreviousCursorCols] <= adj.length) { + // kPreviousCursorCols and cols are visual columns that include the + // continuation prompt width, while adj.length is the raw length of the + // target line. The remembered column is reachable on the target line + // when prevCols - promptLen <= adj.length. + if (this[kPreviousCursorCols] <= adj.length + promptLen) { amountToMove += this[kPreviousCursorCols] - cols; this[kPreviousCursorCols] = -1; } else { diff --git a/test/parallel/test-readline-multiline-remembered-column.js b/test/parallel/test-readline-multiline-remembered-column.js new file mode 100644 index 00000000000000..cbc41c28283e19 --- /dev/null +++ b/test/parallel/test-readline-multiline-remembered-column.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); + +if (process.env.TERM === 'dumb') { + common.skip('skipping - dumb terminal'); +} + +// Regression test: when moving the cursor vertically through a line that is too +// short to hold the current column, readline "remembers" the column and should +// restore it once a subsequent line is long enough. The remembered column is a +// visual column that includes the continuation-prompt width, so it must be +// compared against the target line length plus that prompt width. Previously +// the comparison omitted the prompt width, so the cursor incorrectly clamped to +// the end of the line for columns in the (line length, line length + prompt] +// range. + +const { PassThrough } = require('stream'); +const readline = require('readline'); +const assert = require('assert'); + +const input = new PassThrough(); +const output = new PassThrough(); +output.columns = 80; +output.isTTY = true; + +// The history entry uses '\r' as the line separator; it is displayed as three +// lines: "aaaaa" / "bb" / "cccccc". +const rl = readline.createInterface({ + input, + output, + terminal: true, + prompt: '> ', + history: ['cccccc\rbb\raaaaa'], +}); + +// Load the multiline history entry. +rl.write(null, { name: 'up' }); +assert.strictEqual(rl.line, 'aaaaa\nbb\ncccccc'); + +// Place the cursor at visual column 6 on the bottom line ("cccccc"), which is +// 4 characters into that line. +rl.cursor = 13; +assert.deepStrictEqual(rl.getCursorPos(), { cols: 6, rows: 2 }); + +// Move up onto "bb". It is too short for column 6, so the cursor clamps to the +// end of "bb" and column 6 is remembered. +rl.write(null, { name: 'up' }); +assert.deepStrictEqual(rl.getCursorPos(), { cols: 4, rows: 1 }); + +// Move up onto "aaaaa". Column 6 is reachable here (its columns span 2..7), so +// the remembered column must be restored instead of clamping to the end. +rl.write(null, { name: 'up' }); +assert.strictEqual(rl.cursor, 4); +assert.deepStrictEqual(rl.getCursorPos(), { cols: 6, rows: 0 }); + +rl.close();