Repository: nodejs/node
Pull request #57400: repl: add support for multiline history
Author: login: puskin
Labels: name: readline
description: Issues and PRs related to the built-in readline module.
name: repl
description: Issues and PRs related to the REPL subsystem.
name: semver-minor
description: PRs that contain new features and should be released in the next minor version.
name: notable-change
description: PRs with changes that should be highlighted in changelogs.
name: author ready
description: PRs that have at least one approval, no pending requests for changes, and a CI started.
name: needs-ci
description: PRs that need a full CI run.
name: commit-queue-squash
description: Add this label to instruct the Commit Queue to squash all the PR commits into the first one.
name: backport-requested-v22.x
description: PRs awaiting manual backport to the v22.x-staging branch.
Created: 2025-03-10T14:37:19Z | Merged: 2025-04-13T11:58:02Z

Previously, when navigating the history in the Node.js REPL, multiline commands were displayed line by line, making it difficult to review and edit complete blocks of input.

This PR improves the REPL history behavior by treating multiline commands as a single entry. Now, when scrolling through history, entire blocks of multiline input are displayed together, improving usability and consistency.

### Key changes:

- Adjusted history handling to store and retrieve multiline commands as single entries.
- Updated navigation logic to properly display multiline inputs.
- Improved editing experience, allowing users to modify multiline history entries in place.
- Changed the multiline indicator: from `...` to `|`, to keep the indentation consistent
- Added the multiline indicator also when editing the history, not only when adding new lines
- If the last command caused some kind of error, you can now edit the multiline history, and if you remove the error, the history which caused the error will be replaced with the correct one

## Important:

This change relies on the fact that I am now formatting new history entries and saving them in the `.node_replhistory` file in a specific format. While this does not break backward compatibility, it does mean that switching between older and newer versions (i.e., versions without and with this change) will result in multiline history entries not being preserved if they were created with the latest changes.

### Impact:
This change significantly enhances the REPL experience for users who frequently enter complex or multiline expressions, making command recall and editing more intuitive.

Testing:

- Manually tested with various multiline inputs, both on unix and windows systems
- Verified history navigation behaves as expected with both single-line and multiline commands.

### Demos

Before:  

![before](https://github.com/user-attachments/assets/a54fc893-1783-4c4a-bcbe-e6e4e3a4bc66)

After:  

![after](https://github.com/user-attachments/assets/799ed042-96ad-4d47-a17d-851b56050476)





Diff:
diff --git a/doc/api/repl.md b/doc/api/repl.md
index a134c493a54812..7df45684996eb2 100644
--- a/doc/api/repl.md
+++ b/doc/api/repl.md
@@ -605,7 +605,7 @@ The `replServer.displayPrompt()` method readies the REPL instance for input
 from the user, printing the configured `prompt` to a new line in the `output`
 and resuming the `input` to accept new input.
 
-When multi-line input is being entered, an ellipsis is printed rather than the
+When multi-line input is being entered, a pipe `'|'` is printed rather than the
 'prompt'.
 
 When `preserveCursor` is `true`, the cursor placement will not be reset to `0`.
@@ -657,6 +657,14 @@ A list of the names of all Node.js modules, e.g., `'http'`.
 <!-- YAML
 added: v0.1.91
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/57400
+    description: The multi-line indicator is now "|" instead of "...".
+                 Added support for multi-line history.
+                 It is now possible to "fix" multi-line commands with syntax errors
+                 by visiting the history and editing the command.
+                 When visiting the multiline history from an old node version,
+                 the multiline structure is not preserved.
   - version:
      - v13.4.0
      - v12.17.0
diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js
index c831c79756f343..89936642356109 100644
--- a/lib/internal/readline/interface.js
+++ b/lib/internal/readline/interface.js
@@ -24,8 +24,11 @@ const {
   SafeStringIterator,
   StringPrototypeCodePointAt,
   StringPrototypeEndsWith,
+  StringPrototypeIncludes,
   StringPrototypeRepeat,
+  StringPrototypeReplaceAll,
   StringPrototypeSlice,
+  StringPrototypeSplit,
   StringPrototypeStartsWith,
   StringPrototypeTrim,
   Symbol,
@@ -88,6 +91,10 @@ const ESCAPE_CODE_TIMEOUT = 500;
 // Max length of the kill ring
 const kMaxLengthOfKillRing = 32;
 
+// TODO(puskin94): make this configurable
+const kMultilinePrompt = Symbol('| ');
+const kLastCommandErrored = Symbol('_lastCommandErrored');
+
 const kAddHistory = Symbol('_addHistory');
 const kBeforeEdit = Symbol('_beforeEdit');
 const kDecoder = Symbol('_decoder');
@@ -99,7 +106,9 @@ const kDeleteWordLeft = Symbol('_deleteWordLeft');
 const kDeleteWordRight = Symbol('_deleteWordRight');
 const kGetDisplayPos = Symbol('_getDisplayPos');
 const kHistoryNext = Symbol('_historyNext');
+const kMoveDownOrHistoryNext = Symbol('_moveDownOrHistoryNext');
 const kHistoryPrev = Symbol('_historyPrev');
+const kMoveUpOrHistoryPrev = Symbol('_moveUpOrHistoryPrev');
 const kInsertString = Symbol('_insertString');
 const kLine = Symbol('_line');
 const kLine_buffer = Symbol('_line_buffer');
@@ -109,6 +118,7 @@ const kMoveCursor = Symbol('_moveCursor');
 const kNormalWrite = Symbol('_normalWrite');
 const kOldPrompt = Symbol('_oldPrompt');
 const kOnLine = Symbol('_onLine');
+const kSetLine = Symbol('_setLine');
 const kPreviousKey = Symbol('_previousKey');
 const kPrompt = Symbol('_prompt');
 const kPushToKillRing = Symbol('_pushToKillRing');
@@ -126,12 +136,14 @@ const kTabCompleter = Symbol('_tabCompleter');
 const kTtyWrite = Symbol('_ttyWrite');
 const kUndo = Symbol('_undo');
 const kUndoStack = Symbol('_undoStack');
+const kIsMultiline = Symbol('_isMultiline');
 const kWordLeft = Symbol('_wordLeft');
 const kWordRight = Symbol('_wordRight');
 const kWriteToOutput = Symbol('_writeToOutput');
 const kYank = Symbol('_yank');
 const kYanking = Symbol('_yanking');
 const kYankPop = Symbol('_yankPop');
+const kNormalizeHistoryLineEndings = Symbol('_normalizeHistoryLineEndings');
 
 function InterfaceConstructor(input, output, completer, terminal) {
   this[kSawReturnAt] = 0;
@@ -212,6 +224,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
   const self = this;
 
   this.line = '';
+  this[kIsMultiline] = false;
   this[kSubstringSearch] = null;
   this.output = output;
   this.input = input;
@@ -336,7 +349,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
   }
 
   // Current line
-  this.line = '';
+  this[kSetLine]('');
 
   input.resume();
 }
@@ -410,6 +423,11 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  [kSetLine](line) {
+    this.line = line;
+    this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
+  }
+
   [kOnLine](line) {
     if (this[kQuestionCallback]) {
       const cb = this[kQuestionCallback];
@@ -441,6 +459,20 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  // Convert newlines to a consistent format for history storage
+  [kNormalizeHistoryLineEndings](line, from, to) {
+    // Multiline history entries are saved reversed
+    if (StringPrototypeIncludes(line, '\r')) {
+      // First reverse the lines for proper order, then convert separators
+      return ArrayPrototypeJoin(
+        ArrayPrototypeReverse(StringPrototypeSplit(line, from)),
+        to,
+      );
+    }
+    // For normal cases (saving to history or non-multiline entries)
+    return StringPrototypeReplaceAll(line, from, to);
+  }
+
   [kAddHistory]() {
     if (this.line.length === 0) return '';
 
@@ -449,8 +481,15 @@ class Interface extends InterfaceConstructor {
 
     // If the trimmed line is empty then return the line
     if (StringPrototypeTrim(this.line).length === 0) return this.line;
-
-    if (this.history.length === 0 || this.history[0] !== this.line) {
+    const normalizedLine = this[kNormalizeHistoryLineEndings](this.line, '\n', '\r');
+
+    if (this.history.length === 0 || this.history[0] !== normalizedLine) {
+      if (this[kLastCommandErrored] && this.historyIndex === 0) {
+        // If the last command errored, remove it from history.
+        // The user is issuing a new command starting from the errored command,
+        // Hopefully with the fix
+        ArrayPrototypeShift(this.history);
+      }
       if (this.removeHistoryDuplicates) {
         // Remove older history line if identical to new one
         const dupIndex = ArrayPrototypeIndexOf(this.history, this.line);
@@ -498,8 +537,19 @@ class Interface extends InterfaceConstructor {
     // erase data
     clearScreenDown(this.output);
 
-    // Write the prompt and the current buffer content.
-    this[kWriteToOutput](line);
+    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++) {
+        this[kWriteToOutput](`\n${kMultilinePrompt.description}` + lines[i]);
+      }
+    } else {
+      // Write the prompt and the current buffer content.
+      this[kWriteToOutput](line);
+    }
 
     // Force terminal to allocate a new line
     if (lineCols === 0) {
@@ -632,7 +682,7 @@ class Interface extends InterfaceConstructor {
         this.cursor,
         this.line.length,
       );
-      this.line = beg + c + end;
+      this[kSetLine](beg + c + end);
       this.cursor += c.length;
       this[kRefreshLine]();
     } else {
@@ -680,13 +730,13 @@ class Interface extends InterfaceConstructor {
       this[kInsertString](StringPrototypeSlice(prefix, completeOn.length));
       return;
     } else if (!StringPrototypeStartsWith(completeOn, prefix)) {
-      this.line = StringPrototypeSlice(this.line,
-                                       0,
-                                       this.cursor - completeOn.length) +
+      this[kSetLine](StringPrototypeSlice(this.line,
+                                          0,
+                                          this.cursor - completeOn.length) +
                   prefix +
                   StringPrototypeSlice(this.line,
                                        this.cursor,
-                                       this.line.length);
+                                       this.line.length));
       this.cursor = this.cursor - completeOn.length + prefix.length;
       this[kRefreshLine]();
       return;
@@ -825,7 +875,7 @@ class Interface extends InterfaceConstructor {
   [kDeleteLineLeft]() {
     this[kBeforeEdit](this.line, this.cursor);
     const del = StringPrototypeSlice(this.line, 0, this.cursor);
-    this.line = StringPrototypeSlice(this.line, this.cursor);
+    this[kSetLine](StringPrototypeSlice(this.line, this.cursor));
     this.cursor = 0;
     this[kPushToKillRing](del);
     this[kRefreshLine]();
@@ -834,7 +884,7 @@ class Interface extends InterfaceConstructor {
   [kDeleteLineRight]() {
     this[kBeforeEdit](this.line, this.cursor);
     const del = StringPrototypeSlice(this.line, this.cursor);
-    this.line = StringPrototypeSlice(this.line, 0, this.cursor);
+    this[kSetLine](StringPrototypeSlice(this.line, 0, this.cursor));
     this[kPushToKillRing](del);
     this[kRefreshLine]();
   }
@@ -869,7 +919,7 @@ class Interface extends InterfaceConstructor {
             StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
       const tail =
             StringPrototypeSlice(this.line, this.cursor);
-      this.line = head + currentYank + tail;
+      this[kSetLine](head + currentYank + tail);
       this.cursor = head.length + currentYank.length;
       this[kRefreshLine]();
     }
@@ -878,7 +928,7 @@ class Interface extends InterfaceConstructor {
   clearLine() {
     this[kMoveCursor](+Infinity);
     this[kWriteToOutput]('\r\n');
-    this.line = '';
+    this[kSetLine]('');
     this.cursor = 0;
     this.prevRows = 0;
   }
@@ -907,7 +957,7 @@ class Interface extends InterfaceConstructor {
     );
 
     const entry = ArrayPrototypePop(this[kUndoStack]);
-    this.line = entry.text;
+    this[kSetLine](entry.text);
     this.cursor = entry.cursor;
 
     this[kRefreshLine]();
@@ -922,12 +972,36 @@ class Interface extends InterfaceConstructor {
     );
 
     const entry = ArrayPrototypePop(this[kRedoStack]);
-    this.line = entry.text;
+    this[kSetLine](entry.text);
     this.cursor = entry.cursor;
 
     this[kRefreshLine]();
   }
 
+  [kMoveDownOrHistoryNext]() {
+    const { cols, rows } = this.getCursorPos();
+    const splitLine = StringPrototypeSplit(this.line, '\n');
+    if (!this.historyIndex && rows === splitLine.length) {
+      return;
+    }
+    // Go to the next history only if the cursor is in the first line of the multiline input.
+    // Otherwise treat the "arrow down" as a movement to the next row.
+    if (this[kIsMultiline] && rows < splitLine.length - 1) {
+      const currentLine = splitLine[rows];
+      const nextLine = splitLine[rows + 1];
+      // If I am moving down and the current line is longer than the next line
+      const amountToMove = (cols > nextLine.length + 1) ?
+        currentLine.length - cols + nextLine.length +
+        kMultilinePrompt.description.length + 1 : // Move to the end of the current line
+        // + chars to account for the kMultilinePrompt prefix, + 1 to go to the first char
+        currentLine.length + 1; // Otherwise just move to the next line, in the same position
+      this[kMoveCursor](amountToMove);
+      return;
+    }
+
+    this[kHistoryNext]();
+  }
+
   // TODO(BridgeAR): Add underscores to the search part and a red background in
   // case no match is found. This should only be the visual part and not the
   // actual line content!
@@ -948,9 +1022,9 @@ class Interface extends InterfaceConstructor {
         index--;
       }
       if (index === -1) {
-        this.line = search;
+        this[kSetLine](search);
       } else {
-        this.line = this.history[index];
+        this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
       }
       this.historyIndex = index;
       this.cursor = this.line.length; // Set cursor to end of line.
@@ -958,6 +1032,27 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  [kMoveUpOrHistoryPrev]() {
+    const { cols, rows } = this.getCursorPos();
+    if (this.historyIndex === this.history.length && rows) {
+      return;
+    }
+    // Go to the previous history only if the cursor is in the first line of the multiline input.
+    // Otherwise treat the "arrow up" as a movement to the previous row.
+    if (this[kIsMultiline] && rows > 0) {
+      const splitLine = StringPrototypeSplit(this.line, '\n');
+      const previousLine = splitLine[rows - 1];
+      // If I am moving up and the current line is longer than the previous line
+      const amountToMove = (cols > previousLine.length + 1) ?
+        -cols + 1 : // Move to the beginning of the current line + 1 char to go to the end of the previous line
+        -previousLine.length - 1; // Otherwise just move to the previous line, in the same position
+      this[kMoveCursor](amountToMove);
+      return;
+    }
+
+    this[kHistoryPrev]();
+  }
+
   [kHistoryPrev]() {
     if (this.historyIndex < this.history.length && this.history.length) {
       this[kBeforeEdit](this.line, this.cursor);
@@ -971,9 +1066,9 @@ class Interface extends InterfaceConstructor {
         index++;
       }
       if (index === this.history.length) {
-        this.line = search;
+        this[kSetLine](search);
       } else {
-        this.line = this.history[index];
+        this[kSetLine](this[kNormalizeHistoryLineEndings](this.history[index], '\r', '\n'));
       }
       this.historyIndex = index;
       this.cursor = this.line.length; // Set cursor to end of line.
@@ -987,11 +1082,13 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
+
     for (const char of new SafeStringIterator(str)) {
       if (char === '\n') {
         // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
         rows += MathCeil(offset / col) || 1;
-        offset = 0;
+        // Only add prefix offset for continuation lines in user input (not prompts)
+        offset = this[kIsMultiline] ? kMultilinePrompt.description.length : 0;
         continue;
       }
       // Tabs must be aligned by an offset of the tab size.
@@ -1010,8 +1107,10 @@ class Interface extends InterfaceConstructor {
         offset += 2;
       }
     }
+
     const cols = offset % col;
     rows += (offset - cols) / col;
+
     return { cols, rows };
   }
 
@@ -1024,8 +1123,8 @@ class Interface extends InterfaceConstructor {
    *   }}
    */
   getCursorPos() {
-    const strBeforeCursor =
-      this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
+    const strBeforeCursor = this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
+
     return this[kGetDisplayPos](strBeforeCursor);
   }
 
@@ -1074,7 +1173,7 @@ class Interface extends InterfaceConstructor {
       !key.meta &&
       !key.shift
     ) {
-      if (this[kSubstringSearch] === null) {
+      if (this[kSubstringSearch] === null && !this[kIsMultiline]) {
         this[kSubstringSearch] = StringPrototypeSlice(
           this.line,
           0,
@@ -1308,11 +1407,11 @@ class Interface extends InterfaceConstructor {
           break;
 
         case 'up':
-          this[kHistoryPrev]();
+          this[kMoveUpOrHistoryPrev]();
           break;
 
         case 'down':
-          this[kHistoryNext]();
+          this[kMoveDownOrHistoryNext]();
           break;
 
         case 'tab':
@@ -1388,12 +1487,14 @@ module.exports = {
   kHistoryNext,
   kHistoryPrev,
   kInsertString,
+  kIsMultiline,
   kLine,
   kLine_buffer,
   kMoveCursor,
   kNormalWrite,
   kOldPrompt,
   kOnLine,
+  kSetLine,
   kPreviousKey,
   kPrompt,
   kQuestion,
@@ -1410,4 +1511,7 @@ module.exports = {
   kWordLeft,
   kWordRight,
   kWriteToOutput,
+  kMultilinePrompt,
+  kLastCommandErrored,
+  kNormalizeHistoryLineEndings,
 };
diff --git a/lib/internal/repl/history.js b/lib/internal/repl/history.js
index d8c36170639fc9..09647493895707 100644
--- a/lib/internal/repl/history.js
+++ b/lib/internal/repl/history.js
@@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\r?\n+/, data, repl.historySize);
     } else {
       repl.history = [];
     }
@@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
     fs.write(repl._historyHandle, historyData, 0, 'utf8', onwritten);
   }
 
diff --git a/lib/internal/repl/utils.js b/lib/internal/repl/utils.js
index 126f8ae85d0977..b93ba335fec53a 100644
--- a/lib/internal/repl/utils.js
+++ b/lib/internal/repl/utils.js
@@ -35,6 +35,11 @@ const {
   moveCursor,
 } = require('internal/readline/callbacks');
 
+const {
+  kIsMultiline,
+  kSetLine,
+} = require('internal/readline/interface');
+
 const {
   commonPrefix,
   kSubstringSearch,
@@ -361,8 +366,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
   }
 
   const showPreview = (showCompletion = true) => {
-    // Prevent duplicated previews after a refresh.
-    if (inputPreview !== null || !repl.isCompletionEnabled || !process.features.inspector) {
+    // Prevent duplicated previews after a refresh or in a multiline command.
+    if (inputPreview !== null ||
+        repl[kIsMultiline] ||
+        !repl.isCompletionEnabled ||
+        !process.features.inspector) {
       return;
     }
 
@@ -444,9 +452,14 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
 
       const { cursorPos, displayPos } = getPreviewPos();
       const rows = displayPos.rows - cursorPos.rows;
+      // Moves one line below all the user lines
       moveCursor(repl.output, 0, rows);
+      // Writes the preview there
       repl.output.write(`\n${result}`);
+
+      // Go back to the horizontal position of the cursor
       cursorTo(repl.output, cursorPos.cols);
+      // Go back to the vertical position of the cursor
       moveCursor(repl.output, 0, -rows - 1);
     };
 
@@ -672,7 +685,7 @@ function setupReverseSearch(repl) {
     // line to the found entry.
     if (!isInReverseSearch) {
       if (lastMatch !== -1) {
-        repl.line = repl.history[lastMatch];
+        repl[kSetLine](repl.history[lastMatch]);
         repl.cursor = lastCursor;
         repl.historyIndex = lastMatch;
       }
diff --git a/lib/repl.js b/lib/repl.js
index 6cfceacac620f0..fd61add9fd25d8 100644
--- a/lib/repl.js
+++ b/lib/repl.js
@@ -53,6 +53,7 @@ const {
   ArrayPrototypePop,
   ArrayPrototypePush,
   ArrayPrototypePushApply,
+  ArrayPrototypeReverse,
   ArrayPrototypeShift,
   ArrayPrototypeSlice,
   ArrayPrototypeSome,
@@ -192,6 +193,11 @@ const {
 const {
   makeContextifyScript,
 } = require('internal/vm');
+const {
+  kMultilinePrompt,
+  kLastCommandErrored,
+  kNormalizeHistoryLineEndings,
+} = require('internal/readline/interface');
 let nextREPLResourceNumber = 1;
 // This prevents v8 code cache from getting confused and using a different
 // cache from a resource of the same name
@@ -927,6 +933,8 @@ function REPLServer(prompt,
       debug('finish', e, ret);
       ReflectApply(_memory, self, [cmd]);
 
+      self[kLastCommandErrored] = false;
+
       if (e && !self[kBufferedCommandSymbol] &&
           StringPrototypeStartsWith(StringPrototypeTrim(cmd), 'npm ') &&
           !(e instanceof Recoverable)
@@ -949,7 +957,28 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
+        self.history[0] = self[kNormalizeHistoryLineEndings](cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        lines[lines.length - 1] = cmd;
+        const newHistoryLine = ArrayPrototypeJoin(ArrayPrototypeReverse(lines), '\r');
+        if (self.history[0] !== newHistoryLine) {
+          ArrayPrototypeUnshift(self.history, newHistoryLine);
+        }
+      }
+
+      if (e) {
         self._domain.emit('error', e.err || e);
+        self[kLastCommandErrored] = true;
       }
 
       // Clear buffer if no SyntaxErrors
@@ -1189,10 +1218,7 @@ REPLServer.prototype.resetContext = function() {
 REPLServer.prototype.displayPrompt = function(preserveCursor) {
   let prompt = this._initialPrompt;
   if (this[kBufferedCommandSymbol].length) {
-    prompt = '...';
-    const len = this.lines.level.length ? this.lines.level.length - 1 : 0;
-    const levelInd = StringPrototypeRepeat('..', len);
-    prompt += levelInd + ' ';
+    prompt = kMultilinePrompt.description;
   }
 
   // Do not overwrite `_initialPrompt` here
diff --git a/test/fixtures/.node_repl_history_multiline b/test/fixtures/.node_repl_history_multiline
new file mode 100644
index 00000000000000..9f1ccf5e9af3f5
--- /dev/null
+++ b/test/fixtures/.node_repl_history_multiline
@@ -0,0 +1,4 @@
+]  }    ]      }        b: 4,        a: 3,      {    c: [{ a: 1, b: 2 },    b: 4,    a: 3,  {  },    b: 2,    a: 1,  {var d = [
+]  }    b: 2,    a: 1,  {const c = [
+]`  4,  3,  2,  1,`const b = [
+I can be as long as I want`I am a multiline stringa = `
\ No newline at end of file
diff --git a/test/parallel/test-repl-history-navigation.js b/test/parallel/test-repl-history-navigation.js
index 64317be960e8d1..cb3a50a27c65dc 100644
--- a/test/parallel/test-repl-history-navigation.js
+++ b/test/parallel/test-repl-history-navigation.js
@@ -633,6 +633,191 @@ const tests = [
     ],
     clean: false
   },
+  {
+    // Test that the multiline history is correctly navigated and it can be edited
+    env: { NODE_REPL_HISTORY: defaultHistoryPath },
+    skip: !process.features.inspector,
+    test: [
+      'let a = ``',
+      ENTER,
+      'a = `I am a multiline strong',
+      ENTER,
+      'which ends here`',
+      ENTER,
+      UP,
+      // press LEFT 19 times to reach the typo
+      ...Array(19).fill(LEFT),
+      BACKSPACE,
+      'i',
+      ENTER,
+    ],
+    expected: [
+      prompt, ...'let a = ``',
+      'undefined\n',
+      prompt, ...'a = `I am a multiline strong', // New Line, the user pressed ENTER
+      '| ',
+      ...'which ends here`', // New Line, the user pressed ENTER
+      "'I am a multiline strong\\nwhich ends here'\n", // This is the result printed to the console
+      prompt,
+      `${prompt}a = \`I am a multiline strong`, // This is the history being shown and navigated
+      `\n| which ends here\``,
+      `${prompt}a = \`I am a multiline strong`, // This is the history being shown and navigated
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline strng`, // This is the history being shown and edited
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline string`, // This is the history being shown and edited
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline string`, // This is the history being shown and edited
+      `\n| which ends here\``,
+      "'I am a multiline string\\nwhich ends here'\n", // This is the result printed to the console
+      prompt,
+    ],
+    clean: true
+  },
+  {
+    // Test that the previous multiline history can only be accessed going through the entirety of the current
+    // One navigating its all lines first.
+    env: { NODE_REPL_HISTORY: defaultHistoryPath },
+    skip: !process.features.inspector,
+    test: [
+      'let b = ``',
+      ENTER,
+      'b = `I am a multiline strong',
+      ENTER,
+      'which ends here`',
+      ENTER,
+      'let c = `I',
+      ENTER,
+      'am another one`',
+      ENTER,
+      UP,
+      UP,
+      UP,
+      UP,
+      // press RIGHT 10 times to reach the typo
+      ...Array(10).fill(RIGHT),
+      BACKSPACE,
+      'i',
+      ENTER,
+    ],
+    expected: [
+      prompt, ...'let b = ``',
+      'undefined\n',
+      prompt, ...'b = `I am a multiline strong', // New Line, the user pressed ENTER
+      '| ',
+      ...'which ends here`', // New Line, the user pressed ENTER
+      "'I am a multiline strong\\nwhich ends here'\n", // This is the result printed to the console
+      prompt, ...'let c = `I', // New Line, the user pressed ENTER
+      '| ',
+      ...'am another one`', // New Line, the user pressed ENTER
+      'undefined\n',
+      prompt,
+      `${prompt}let c = \`I`, // This is the history being shown and navigated
+      `\n| am another one\``,
+
+      `${prompt}let c = \`I`, // This is the history being shown and navigated
+      `\n| am another one\``,
+
+      `${prompt}b = \`I am a multiline strong`, // This is the history being shown and edited
+      `\n| which ends here\``,
+      `${prompt}b = \`I am a multiline strong`, // This is the history being shown and edited
+      `\n| which ends here\``,
+      `${prompt}b = \`I am a multiline strng`, // This is the history being shown and edited
+      `\n| which ends here\``,
+
+      `${prompt}b = \`I am a multiline string`, // This is the history being shown and edited
+      `\n| which ends here\``,
+
+      `${prompt}b = \`I am a multiline string`, // This is the history being shown and edited
+      `\n| which ends here\``,
+      "'I am a multiline string\\nwhich ends here'\n", // This is the result printed to the console
+      prompt,
+    ],
+    clean: true
+  },
+  {
+    // Test that we can recover from a line with a syntax error
+    env: { NODE_REPL_HISTORY: defaultHistoryPath },
+    skip: !process.features.inspector,
+    test: [
+      'let d = ``',
+      ENTER,
+      'd = `I am a',
+      ENTER,
+      'super',
+      ENTER,
+      'broken` line\'',
+      ENTER,
+      UP,
+      BACKSPACE,
+      '`',
+      // press LEFT 6 times to reach the typo
+      ...Array(6).fill(LEFT),
+      BACKSPACE,
+      ENTER,
+    ],
+    expected: [
+      prompt, ...'let d = ``', // New Line, the user pressed ENTER
+      'undefined\n',
+      prompt, ...'d = `I am a', // New Line, the user pressed ENTER
+      '| ',
+      ...'super', // New Line, the user pressed ENTER
+      '| ',
+      ...'broken` line\'', // New Line, the user pressed ENTER
+      "[broken` line'\n" +
+      '        ^^^^\n' +
+      '\n' +
+      "Uncaught SyntaxError: Unexpected identifier 'line'\n" +
+      '] {\n' +
+      '  [stack]: [Getter/Setter],\n' +
+      `  [message]: "Unexpected identifier 'line'"\n` +
+      '}\n',
+      prompt,
+      `${prompt}d = \`I am a`, // This is the history being shown and edited
+      `\n| super`,
+      `\n| broken\` line'`,
+
+      `${prompt}d = \`I am a`, // This is the history being shown and edited
+      `\n| super`,
+      '\n| broken` line',
+      '`',
+
+      `${prompt}d = \`I am a`, // This is the history being shown and edited
+      `\n| super`,
+      `\n| broken line\``,
+      "'I am a\\nsuper\\nbroken line'\n", // This is the result printed to the console
+      prompt,
+    ],
+    clean: true
+  },
+  {
+    // Test that multiline history is not duplicated
+    env: { NODE_REPL_HISTORY: defaultHistoryPath },
+    skip: !process.features.inspector,
+    test: [
+      'let f = `multiline',
+      ENTER,
+      'string`',
+      ENTER,
+      UP, UP, UP,
+    ],
+    expected: [
+      prompt, ...'let f = `multiline',
+      '| ',
+      ...'string`',
+      'undefined\n',
+      prompt,
+      `${prompt}let f = \`multiline`,
+      `\n| string\``,
+      `${prompt}let f = \`multiline`,
+      `\n| string\``,
+      prompt,
+    ],
+    clean: true
+  },
 ];
 const numtests = tests.length;
 
diff --git a/test/parallel/test-repl-load-multiline-from-history.js b/test/parallel/test-repl-load-multiline-from-history.js
new file mode 100644
index 00000000000000..5814e3819da191
--- /dev/null
+++ b/test/parallel/test-repl-load-multiline-from-history.js
@@ -0,0 +1,96 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fixtures = require('../common/fixtures');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+{
+  // Testing multiline history loading
+  const tmpdir = require('../common/tmpdir');
+  tmpdir.refresh();
+  const replHistoryPath = fixtures.path('.node_repl_history_multiline');
+
+  const checkResults = common.mustSucceed((r) => {
+    assert.strictEqual(r.history.length, 4);
+
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.line, 'var d = [\n' +
+      '  {\n' +
+      '    a: 1,\n' +
+      '    b: 2,\n' +
+      '  },\n' +
+      '  {\n' +
+      '    a: 3,\n' +
+      '    b: 4,\n' +
+      '    c: [{ a: 1, b: 2 },\n' +
+      '      {\n' +
+      '        a: 3,\n' +
+      '        b: 4,\n' +
+      '      }\n' +
+      '    ]\n' +
+      '  }\n' +
+      ']'
+    );
+
+    // Move the cursor all lines up until the former entry is retrieved.
+    for (let i = 0; i < r.line.split('\n').length; i++) {
+      r.input.run([{ name: 'up' }]);
+    }
+    assert.strictEqual(r.line, 'const c = [\n  {\n    a: 1,\n    b: 2,\n  }\n]');
+
+    // Move the cursor all lines up until the former entry is retrieved.
+    for (let i = 0; i < r.line.split('\n').length; i++) {
+      r.input.run([{ name: 'up' }]);
+    }
+    assert.strictEqual(r.line, '`const b = [\n  1,\n  2,\n  3,\n  4,\n]`');
+
+    // Move the cursor all lines up until the former entry is retrieved.
+    for (let i = 0; i < r.line.split('\n').length; i++) {
+      r.input.run([{ name: 'up' }]);
+    }
+    assert.strictEqual(r.line, 'a = `\nI am a multiline string\nI can be as long as I want`');
+  });
+
+  repl.createInternalRepl(
+    { NODE_REPL_HISTORY: replHistoryPath },
+    {
+      terminal: true,
+      input: new ActionStream(),
+      output: new stream.Writable({
+        write(chunk, _, next) {
+          next();
+        }
+      }),
+    },
+    checkResults
+  );
+}
diff --git a/test/parallel/test-repl-multiline-navigation.js b/test/parallel/test-repl-multiline-navigation.js
new file mode 100644
index 00000000000000..f8bdcc842d595e
--- /dev/null
+++ b/test/parallel/test-repl-multiline-navigation.js
@@ -0,0 +1,200 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fs = require('fs');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  write(chunk) {
+    const chunkLines = chunk.toString('utf8').split('\n');
+    this.lines[this.lines.length - 1] += chunkLines[0];
+    if (chunkLines.length > 1) {
+      this.lines.push(...chunkLines.slice(1));
+    }
+    this.emit('line', this.lines[this.lines.length - 1]);
+    return true;
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+function cleanupTmpFile() {
+  try {
+    // Write over the file, clearing any history
+    fs.writeFileSync(defaultHistoryPath, '');
+  } catch (err) {
+    if (err.code === 'ENOENT') return true;
+    throw err;
+  }
+  return true;
+}
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
+
+{
+  cleanupTmpFile();
+  // Make sure the cursor is at the right places.
+  // If the cursor is at the end of a long line and the down key is pressed,
+  // Move the cursor to the end of the next line, if shorter.
+  const checkResults = common.mustSucceed((r) => {
+    r.write('let str = `');
+    r.input.run([{ name: 'enter' }]);
+    r.write('111');
+    r.input.run([{ name: 'enter' }]);
+    r.write('22222222222222');
+    r.input.run([{ name: 'enter' }]);
+    r.write('3`');
+    r.input.run([{ name: 'enter' }]);
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.cursor, 33);
+
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.cursor, 18);
+
+    for (let i = 0; i < 5; i++) {
+      r.input.run([{ name: 'right' }]);
+    }
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.cursor, 15);
+    r.input.run([{ name: 'up' }]);
+
+    for (let i = 0; i < 5; i++) {
+      r.input.run([{ name: 'right' }]);
+    }
+    assert.strictEqual(r.cursor, 8);
+
+    r.input.run([{ name: 'down' }]);
+    assert.strictEqual(r.cursor, 15);
+
+    r.input.run([{ name: 'down' }]);
+    assert.strictEqual(r.cursor, 19);
+  });
+
+  repl.createInternalRepl(
+    { NODE_REPL_HISTORY: defaultHistoryPath },
+    {
+      terminal: true,
+      input: new ActionStream(),
+      output: new stream.Writable({
+        write(chunk, _, next) {
+          next();
+        }
+      }),
+    },
+    checkResults
+  );
+}
+
+{
+  cleanupTmpFile();
+  // If the last command errored and the user is trying to edit it,
+  // The errored line should be removed from history
+  const checkResults = common.mustSucceed((r) => {
+    r.write('let lineWithMistake = `I have some');
+    r.input.run([{ name: 'enter' }]);
+    r.write('problem with` my syntax\'');
+    r.input.run([{ name: 'enter' }]);
+    r.input.run([{ name: 'up' }]);
+    r.input.run([{ name: 'backspace' }]);
+    r.write('`');
+    for (let i = 0; i < 11; i++) {
+      r.input.run([{ name: 'left' }]);
+    }
+    r.input.run([{ name: 'backspace' }]);
+    r.input.run([{ name: 'enter' }]);
+
+    assert.strictEqual(r.history.length, 1);
+    assert.strictEqual(r.history[0], 'let lineWithMistake = `I have some\rproblem with my syntax`');
+    assert.strictEqual(r.line, '');
+  });
+
+  repl.createInternalRepl(
+    { NODE_REPL_HISTORY: defaultHistoryPath },
+    {
+      terminal: true,
+      input: new ActionStream(),
+      output: new stream.Writable({
+        write(chunk, _, next) {
+          next();
+        }
+      }),
+    },
+    checkResults
+  );
+}
+
+{
+  cleanupTmpFile();
+  const outputBuffer = [];
+
+  // Test that the REPL preview is properly shown on multiline commands
+  // And deleted when enter is pressed
+  const checkResults = common.mustSucceed((r) => {
+    r.write('Array(100).fill(');
+    r.input.run([{ name: 'enter' }]);
+    r.write('123');
+    r.input.run([{ name: 'enter' }]);
+    r.write(')');
+    r.input.run([{ name: 'enter' }]);
+    r.input.run([{ name: 'up' }]);
+    r.input.run([{ name: 'up' }]);
+
+    assert.deepStrictEqual(r.last, new Array(100).fill(123));
+    r.input.run([{ name: 'enter' }]);
+    assert.strictEqual(outputBuffer.includes('[\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123, 123, 123, 123, 123, 123, 123, 123, 123, 123, 123,\n' +
+    '  123\n' +
+    ']\n'), true);
+  });
+
+  repl.createInternalRepl(
+    { NODE_REPL_HISTORY: defaultHistoryPath },
+    {
+      preview: true,
+      terminal: true,
+      input: new ActionStream(),
+      output: new stream.Writable({
+        write(chunk, _, next) {
+          // Store each chunk in the buffer
+          outputBuffer.push(chunk.toString());
+          next();
+        }
+      }),
+    },
+    checkResults
+  );
+}
diff --git a/test/parallel/test-repl-multiline.js b/test/parallel/test-repl-multiline.js
index e458555cd87f76..0a2f95668a1683 100644
--- a/test/parallel/test-repl-multiline.js
+++ b/test/parallel/test-repl-multiline.js
@@ -26,7 +26,7 @@ function run({ useColors }) {
     // Validate the output, which contains terminal escape codes.
     assert.strictEqual(actual.length, 6);
     assert.ok(actual[0].endsWith(input[0]));
-    assert.ok(actual[1].includes('... '));
+    assert.ok(actual[1].includes('| '));
     assert.ok(actual[1].endsWith(input[1]));
     assert.ok(actual[2].includes('undefined'));
     assert.ok(actual[3].endsWith(input[2]));
diff --git a/test/parallel/test-repl-persistent-history.js b/test/parallel/test-repl-persistent-history.js
index f5e2d48139f449..2ec5a315c8a7c3 100644
--- a/test/parallel/test-repl-persistent-history.js
+++ b/test/parallel/test-repl-persistent-history.js
@@ -9,7 +9,6 @@ const REPL = require('internal/repl');
 const assert = require('assert');
 const fs = require('fs');
 const os = require('os');
-const util = require('util');
 
 if (process.env.TERM === 'dumb') {
   common.skip('skipping - dumb terminal');
@@ -41,11 +40,11 @@ class ActionStream extends stream.Stream {
       if (typeof action === 'object') {
         this.emit('keypress', '', action);
       } else {
-        this.emit('data', `${action}\n`);
+        this.emit('data', action);
       }
       setImmediate(doAction);
     };
-    setImmediate(doAction);
+    doAction();
   }
   resume() {}
   pause() {}
@@ -97,8 +96,8 @@ const tests = [
     test: [UP, '21', ENTER, "'42'", ENTER],
     expected: [
       prompt,
-      '2', '1', '21\n', prompt, prompt,
-      "'", '4', '2', "'", "'42'\n", prompt, prompt,
+      '2', '1', '21\n', prompt,
+      "'", '4', '2', "'", "'42'\n", prompt,
     ],
     clean: false
   },
@@ -195,8 +194,6 @@ function runTest(assertCleaned) {
   const opts = tests.shift();
   if (!opts) return; // All done
 
-  console.log('NEW');
-
   if (assertCleaned) {
     try {
       assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), '');
@@ -221,7 +218,6 @@ function runTest(assertCleaned) {
     output: new stream.Writable({
       write(chunk, _, next) {
         const output = chunk.toString();
-        console.log('INPUT', util.inspect(output));
 
         // Ignore escapes and blank lines
         if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output))
diff --git a/test/parallel/test-repl-programmatic-history.js b/test/parallel/test-repl-programmatic-history.js
index aae15eb752c862..4e8887e0e08d56 100644
--- a/test/parallel/test-repl-programmatic-history.js
+++ b/test/parallel/test-repl-programmatic-history.js
@@ -7,7 +7,6 @@ const REPL = require('repl');
 const assert = require('assert');
 const fs = require('fs');
 const os = require('os');
-const util = require('util');
 
 if (process.env.TERM === 'dumb') {
   common.skip('skipping - dumb terminal');
@@ -39,11 +38,11 @@ class ActionStream extends stream.Stream {
       if (typeof action === 'object') {
         this.emit('keypress', '', action);
       } else {
-        this.emit('data', `${action}\n`);
+        this.emit('data', action);
       }
       setImmediate(doAction);
     };
-    setImmediate(doAction);
+    doAction();
   }
   resume() {}
   pause() {}
@@ -95,10 +94,8 @@ const tests = [
     test: [UP, '21', ENTER, "'42'", ENTER],
     expected: [
       prompt,
-      // TODO(BridgeAR): The line is refreshed too many times. The double prompt
-      // is redundant and can be optimized away.
-      '2', '1', '21\n', prompt, prompt,
-      "'", '4', '2', "'", "'42'\n", prompt, prompt,
+      '2', '1', '21\n', prompt,
+      "'", '4', '2', "'", "'42'\n", prompt,
     ],
     clean: false
   },
@@ -191,8 +188,6 @@ function runTest(assertCleaned) {
   const opts = tests.shift();
   if (!opts) return; // All done
 
-  console.log('NEW');
-
   if (assertCleaned) {
     try {
       assert.strictEqual(fs.readFileSync(defaultHistoryPath, 'utf8'), '');
@@ -218,7 +213,6 @@ function runTest(assertCleaned) {
     output: new stream.Writable({
       write(chunk, _, next) {
         const output = chunk.toString();
-        console.log('INPUT', util.inspect(output));
 
         // Ignore escapes and blank lines
         if (output.charCodeAt(0) === 27 || /^[\r\n]+$/.test(output))
diff --git a/test/parallel/test-repl-recoverable.js b/test/parallel/test-repl-recoverable.js
index a975dac782cfe4..74dcd5dfbf585d 100644
--- a/test/parallel/test-repl-recoverable.js
+++ b/test/parallel/test-repl-recoverable.js
@@ -18,7 +18,7 @@ function customEval(code, context, file, cb) {
 const putIn = new ArrayStream();
 
 putIn.write = function(msg) {
-  if (msg === '... ') {
+  if (msg === '| ') {
     recovered = true;
   }
 
diff --git a/test/parallel/test-repl-reverse-search.js b/test/parallel/test-repl-reverse-search.js
index 246488cbd0ef5f..314d772adc67a5 100644
--- a/test/parallel/test-repl-reverse-search.js
+++ b/test/parallel/test-repl-reverse-search.js
@@ -28,7 +28,9 @@ class ActionStream extends stream.Stream {
       const next = _iter.next();
       if (next.done) {
         // Close the repl. Note that it must have a clean prompt to do so.
-        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        setImmediate(() => {
+          this.emit('keypress', '', { ctrl: true, name: 'd' });
+        });
         return;
       }
       const action = next.value;
@@ -36,7 +38,7 @@ class ActionStream extends stream.Stream {
       if (typeof action === 'object') {
         this.emit('keypress', '', action);
       } else {
-        this.emit('data', `${action}`);
+        this.emit('data', action);
       }
       setImmediate(doAction);
     };
diff --git a/test/parallel/test-repl-top-level-await.js b/test/parallel/test-repl-top-level-await.js
index c8bc26fad62e5c..e07b27948b93d8 100644
--- a/test/parallel/test-repl-top-level-await.js
+++ b/test/parallel/test-repl-top-level-await.js
@@ -96,7 +96,7 @@ async function ordinaryTests() {
     ['const m = foo(await koo());'],
     ['m', '4'],
     ['const n = foo(await\nkoo());',
-     ['const n = foo(await\r', '... koo());\r', 'undefined']],
+     ['const n = foo(await\r', '| koo());\r', 'undefined']],
     ['n', '4'],
     // eslint-disable-next-line no-template-curly-in-string
     ['`status: ${(await Promise.resolve({ status: 200 })).status}`',
@@ -145,20 +145,20 @@ async function ordinaryTests() {
     ],
     ['for (const x of [1,2,3]) {\nawait x\n}', [
       'for (const x of [1,2,3]) {\r',
-      '... await x\r',
-      '... }\r',
+      '| await x\r',
+      '| }\r',
       'undefined',
     ]],
     ['for (const x of [1,2,3]) {\nawait x;\n}', [
       'for (const x of [1,2,3]) {\r',
-      '... await x;\r',
-      '... }\r',
+      '| await x;\r',
+      '| }\r',
       'undefined',
     ]],
     ['for await (const x of [1,2,3]) {\nconsole.log(x)\n}', [
       'for await (const x of [1,2,3]) {\r',
-      '... console.log(x)\r',
-      '... }\r',
+      '| console.log(x)\r',
+      '| }\r',
       '1',
       '2',
       '3',
@@ -166,8 +166,8 @@ async function ordinaryTests() {
     ]],
     ['for await (const x of [1,2,3]) {\nconsole.log(x);\n}', [
       'for await (const x of [1,2,3]) {\r',
-      '... console.log(x);\r',
-      '... }\r',
+      '| console.log(x);\r',
+      '| }\r',
       '1',
       '2',
       '3',
@@ -192,7 +192,7 @@ async function ordinaryTests() {
     } else if ('line' in options) {
       assert.strictEqual(lines[toBeRun.length + options.line], expected);
     } else {
-      const echoed = toBeRun.map((a, i) => `${i > 0 ? '... ' : ''}${a}\r`);
+      const echoed = toBeRun.map((a, i) => `${i > 0 ? '| ' : ''}${a}\r`);
       assert.deepStrictEqual(lines, [...echoed, expected, PROMPT]);
     }
   }
diff --git a/test/parallel/test-repl-unexpected-token-recoverable.js b/test/parallel/test-repl-unexpected-token-recoverable.js
index ddab589ddec8b9..2f3bd38d809303 100644
--- a/test/parallel/test-repl-unexpected-token-recoverable.js
+++ b/test/parallel/test-repl-unexpected-token-recoverable.js
@@ -12,7 +12,7 @@ const child = spawn(process.execPath, args);
 
 const input = 'const foo = "bar\\\nbaz"';
 // Match '...' as well since it marks a multi-line statement
-const expectOut = /> \.\.\. undefined\n/;
+const expectOut = /> \| undefined\n/;
 
 child.stderr.setEncoding('utf8');
 child.stderr.on('data', (d) => {
diff --git a/test/parallel/test-repl.js b/test/parallel/test-repl.js
index 6c16d2a9679b21..9f14ebb39eeac7 100644
--- a/test/parallel/test-repl.js
+++ b/test/parallel/test-repl.js
@@ -69,7 +69,7 @@ async function runReplTests(socket, prompt, tests) {
 
         // Allow to match partial text if no newline was received, because
         // sending newlines from the REPL itself would be redundant
-        // (e.g. in the `... ` multiline prompt: The user already pressed
+        // (e.g. in the `| ` multiline prompt: The user already pressed
         // enter for that, so the REPL shouldn't do it again!).
         if (lineBuffer === expectedLine && !expectedLine.includes('\n'))
           lineBuffer += '\n';
@@ -154,7 +154,7 @@ const errorTests = [
   // Common syntax error is treated as multiline command
   {
     send: 'function test_func() {',
-    expect: '... '
+    expect: '| '
   },
   // You can recover with the .break command
   {
@@ -169,7 +169,7 @@ const errorTests = [
   // Can handle multiline template literals
   {
     send: '`io.js',
-    expect: '... '
+    expect: '| '
   },
   // Special REPL commands still available
   {
@@ -179,7 +179,7 @@ const errorTests = [
   // Template expressions
   {
     send: '`io.js ${"1.0"',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '+ ".2"}`',
@@ -187,7 +187,7 @@ const errorTests = [
   },
   {
     send: '`io.js ${',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '"1.0" + ".2"}`',
@@ -196,7 +196,7 @@ const errorTests = [
   // Dot prefix in multiline commands aren't treated as commands
   {
     send: '("a"',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '.charAt(0))',
@@ -330,7 +330,7 @@ const errorTests = [
   // Multiline object
   {
     send: '{ a: ',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '1 }',
@@ -339,7 +339,7 @@ const errorTests = [
   // Multiline string-keyed object (e.g. JSON)
   {
     send: '{ "a": ',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '1 }',
@@ -348,12 +348,12 @@ const errorTests = [
   // Multiline class with private member.
   {
     send: 'class Foo { #private = true ',
-    expect: '... '
+    expect: '| '
   },
   // Class field with bigint.
   {
     send: 'num = 123456789n',
-    expect: '... '
+    expect: '| '
   },
   // Static class features.
   {
@@ -363,15 +363,15 @@ const errorTests = [
   // Multiline anonymous function with comment
   {
     send: '(function() {',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '// blah',
-    expect: '... '
+    expect: '| '
   },
   {
     send: 'return 1n;',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '})()',
@@ -380,11 +380,11 @@ const errorTests = [
   // Multiline function call
   {
     send: 'function f(){}; f(f(1,',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '2)',
-    expect: '... '
+    expect: '| '
   },
   {
     send: ')',
@@ -405,16 +405,16 @@ const errorTests = [
   ...possibleTokensAfterIdentifierWithLineBreak.map((token) => (
     {
       send: `npm ${token}; undefined`,
-      expect: '... undefined'
+      expect: '| undefined'
     }
   )),
   {
     send: '(function() {\n\nreturn 1;\n})()',
-    expect: '... ... ... 1'
+    expect: '| | | 1'
   },
   {
     send: '{\n\na: 1\n}',
-    expect: '... ... ... { a: 1 }'
+    expect: '| | | { a: 1 }'
   },
   {
     send: 'url.format("http://google.com")',
@@ -449,7 +449,7 @@ const errorTests = [
   // Do not fail when a String is created with line continuation
   {
     send: '\'the\\\nfourth\\\neye\'',
-    expect: ['... ... \'thefourtheye\'']
+    expect: ['| | \'thefourtheye\'']
   },
   // Don't fail when a partial String is created and line continuation is used
   // with whitespace characters at the end of the string. We are to ignore it.
@@ -462,17 +462,17 @@ const errorTests = [
   // Multiline strings preserve whitespace characters in them
   {
     send: '\'the \\\n   fourth\t\t\\\n  eye  \'',
-    expect: '... ... \'the    fourth\\t\\t  eye  \''
+    expect: '| | \'the    fourth\\t\\t  eye  \''
   },
   // More than one multiline strings also should preserve whitespace chars
   {
     send: '\'the \\\n   fourth\' +  \'\t\t\\\n  eye  \'',
-    expect: '... ... \'the    fourth\\t\\t  eye  \''
+    expect: '| | \'the    fourth\\t\\t  eye  \''
   },
   // using REPL commands within a string literal should still work
   {
     send: '\'\\\n.break',
-    expect: '... ' + prompt_unix
+    expect: '| ' + prompt_unix
   },
   // Using REPL command "help" within a string literal should still work
   {
@@ -519,7 +519,7 @@ const errorTests = [
   // Empty lines in the string literals should not affect the string
   {
     send: '\'the\\\n\\\nfourtheye\'\n',
-    expect: '... ... \'thefourtheye\''
+    expect: '| | \'thefourtheye\''
   },
   // Regression test for https://github.com/nodejs/node/issues/597
   {
@@ -536,32 +536,32 @@ const errorTests = [
   // Regression tests for https://github.com/nodejs/node/issues/2749
   {
     send: 'function x() {\nreturn \'\\n\';\n }',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x() {\nreturn \'\\\\\';\n }',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   // Regression tests for https://github.com/nodejs/node/issues/3421
   {
     send: 'function x() {\n//\'\n }',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x() {\n//"\n }',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x() {//\'\n }',
-    expect: '... undefined'
+    expect: '| undefined'
   },
   {
     send: 'function x() {//"\n }',
-    expect: '... undefined'
+    expect: '| undefined'
   },
   {
     send: 'function x() {\nvar i = "\'";\n }',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x(/*optional*/) {}',
@@ -589,7 +589,7 @@ const errorTests = [
   },
   {
     send: '/* \'\n"\n\'"\'\n*/',
-    expect: '... ... ... undefined'
+    expect: '| | | undefined'
   },
   // REPL should get a normal require() function, not one that allows
   // access to internal modules without the --expose-internals flag.
@@ -614,19 +614,19 @@ const errorTests = [
   // REPL should handle quotes within regexp literal in multiline mode
   {
     send: "function x(s) {\nreturn s.replace(/'/,'');\n}",
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: "function x(s) {\nreturn s.replace(/'/,'');\n}",
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x(s) {\nreturn s.replace(/"/,"");\n}',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: 'function x(s) {\nreturn s.replace(/.*/,"");\n}',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
   {
     send: '{ var x = 4; }',
@@ -697,17 +697,17 @@ const errorTests = [
   // https://github.com/nodejs/node/issues/9300
   {
     send: 'function foo() {\nvar bar = 1 / 1; // "/"\n}',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
 
   {
     send: '(function() {\nreturn /foo/ / /bar/;\n}())',
-    expect: '... ... NaN'
+    expect: '| | NaN'
   },
 
   {
     send: '(function() {\nif (false) {} /bar"/;\n}())',
-    expect: '... ... undefined'
+    expect: '| | undefined'
   },
 
   // https://github.com/nodejs/node/issues/16483
@@ -723,7 +723,7 @@ const errorTests = [
   // Newline within template string maintains whitespace.
   {
     send: '`foo \n`',
-    expect: '... \'foo \\n\''
+    expect: '| \'foo \\n\''
   },
   // Whitespace is not evaluated.
   {
@@ -757,7 +757,7 @@ const errorTests = [
   {
     send: 'x = {\nfield\n{',
     expect: [
-      '... ... {',
+      '| | {',
       kArrow,
       '',
       /^Uncaught SyntaxError: /,
@@ -774,11 +774,11 @@ const errorTests = [
   },
   {
     send: 'if (typeof process === "object"); {',
-    expect: '... '
+    expect: '| '
   },
   {
     send: 'console.log("process is defined");',
-    expect: '... '
+    expect: '| '
   },
   {
     send: '} else {',


Reviews:
user: login: BridgeAR
body: This is a great start!

I did play around with it and I found the following issues:
1. A history item is now not properly deduplicated anymore if it spans over multiple lines. That is the case with other entries when jumping through the history.
2. When entering a multiline command that fails to evaluate, it is handled as individual lines. Especially those cases would be great to "correct". For comfort reasons, I would even consider deleting the broken one, if that one is edited and saved successfully right as following command.
3. Moving up and down while working on a multiline entry feels somewhat off. It is opinionated, but I believe the former history entry should only be shown in case the cursor is at the top line while editing a multiline input. The reason is, that I likely want to finish the input and not change to the former history entry.

I would also change the `...` to `|`. That way the indentation while entering something is identical. I guess we can still look into doing this potentially while going through the entries in the history but at least it is nicer during initial entry.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T21:31:32Z
user: login: BridgeAR
body: This is great work! I am looking forward to landing this change!

I left a few comments, mainly to understand some things better.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T13:42:28Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:56:13Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T14:04:42Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T14:22:39Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T14:54:58Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:15:32Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:19:38Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:23:38Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:28:59Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:29:58Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T09:33:13Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T10:02:33Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:48:44Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:53:02Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:55:38Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T12:56:26Z
user: login: BridgeAR
body: https://github.com/nodejs/node/pull/57400/files#diff-f91074d8cb772bd5c2dcd48c4421ca70f0d3bd0f7ea5bbd818bf57fda1202383R608

This would have to be changed in the documentation.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:00:26Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:03:29Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:08:59Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:11:44Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:22:02Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T13:36:11Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T13:50:49Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:10:01Z
user: login: BridgeAR
body: LGTM! Great work!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T16:38:08Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:41:14Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T16:41:38Z
user: login: BridgeAR
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T19:46:38Z
user: login: ljharb
body: seems good
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T20:05:59Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T20:08:15Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T08:52:00Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T09:05:31Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T21:17:21Z
user: login: BridgeAR
body: It is a bit difficult to tell if this change should be considered as minor or major. Any further opinion?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-05T22:40:12Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T07:11:56Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T07:16:07Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:02:59Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:14:43Z
user: login: BridgeAR
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T22:50:29Z
user: login: puskin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T05:55:54Z
user: login: BridgeAR
body: LGTM! Thank you for the great work! 🥳 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-12T10:20:19Z

Review comments:
diff_hunk: @@ -0,0 +1,198 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fs = require('fs');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  write(chunk) {
+    const chunkLines = chunk.toString('utf8').split('\n');
+    this.lines[this.lines.length - 1] += chunkLines[0];
+    if (chunkLines.length > 1) {
+      this.lines.push(...chunkLines.slice(1));
+    }
+    this.emit('line', this.lines[this.lines.length - 1]);
+    return true;
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+function cleanupTmpFile() {
+  try {
+    // Write over the file, clearing any history
+    fs.writeFileSync(defaultHistoryPath, '');
+  } catch (err) {
+    if (err.code === 'ENOENT') return true;
+    throw err;
+  }
+  return true;
+}
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
+
+{
+  cleanupTmpFile();
+  // It moves the cursor at the right places
+  const checkResults = common.mustSucceed((r) => {
+    r.write('let str = `');
+    r.input.run([{ name: 'enter' }]);
+    r.write('111');
+    r.input.run([{ name: 'enter' }]);
+    r.write('22222222222222');
+    r.input.run([{ name: 'enter' }]);
+    r.write('3`');
+    r.input.run([{ name: 'enter' }]);
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.cursor, 33);
path: test/parallel/test-repl-multiline-navigation.js
user: login: BridgeAR
body: I do not understand what the cursor stands for in this case. Could you add a comment or something like that? :)

That also applies to the other positions.
created_at: 2025-03-25T11:41:58Z
author_association: MEMBER
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/[\n]+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: BridgeAR
body: ```suggestion
      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
```
created_at: 2025-03-25T11:44:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fixtures = require('../common/fixtures');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+{
+  // Testing multiline history loading
+  const tmpdir = require('../common/tmpdir');
+  tmpdir.refresh();
+  const replHistoryPath = fixtures.path('.node_repl_history-multiline');
+
+  const checkResults = common.mustSucceed((r) => {
+    assert.strictEqual(r.history.length, 4);
+
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(
+      r.line,
+      'var d = [\n' +
+    '  {\n' +
path: test/parallel/test-repl-load-multiline-from-history.js
user: login: BridgeAR
body: Nit: please adjust the indentation to be on the same line or one indentation level after the first line.
created_at: 2025-03-25T11:46:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fixtures = require('../common/fixtures');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+{
+  // Testing multiline history loading
+  const tmpdir = require('../common/tmpdir');
+  tmpdir.refresh();
+  const replHistoryPath = fixtures.path('.node_repl_history-multiline');
+
+  const checkResults = common.mustSucceed((r) => {
+    assert.strictEqual(r.history.length, 4);
+
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(
+      r.line,
+      'var d = [\n' +
+    '  {\n' +
+    '    a: 1,\n' +
+    '    b: 2,\n' +
+    '  },\n' +
+    '  {\n' +
+    '    a: 3,\n' +
+    '    b: 4,\n' +
+    '    c: [{ a: 1, b: 2 },\n' +
+    '      {\n' +
+    '        a: 3,\n' +
+    '        b: 4,\n' +
+    '      }\n' +
+    '    ]\n' +
+    '  }\n' +
+    ']'
+    );
+
+    for (let i = 0; i < r.line.split('\n').length; i++) {
path: test/parallel/test-repl-load-multiline-from-history.js
user: login: BridgeAR
body: Maybe add a comment what this does? :) Something like

```suggestion
    // Move the cursor all lines up until the former entry is retrieved.
    for (let i = 0; i < r.line.split('\n').length; i++) {
```
created_at: 2025-03-25T11:48:04Z
author_association: MEMBER
diff_hunk: @@ -155,8 +155,15 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
 
   let escaped = null;
 
+  let justExecutedMultilineCommand = false;
+
   function getPreviewPos() {
     const displayPos = repl._getDisplayPos(`${repl.getPrompt()}${repl.line}`);
+    // If the line is a multi line
+    if (StringPrototypeIndexOf(repl.line, '\n') !== -1) {
+      // This is to consider the additional "| " that prefixes the line
+      displayPos.cols += 2;
path: lib/internal/repl/utils.js
user: login: BridgeAR
body: I believe (and I might be mistaken, while it would be good why not in that case) this should be moved into `repl._getDisplayPos()`. That way all usages will also automatically handle this correct. I also wonder if we have situations where the user changes the pipe to something else. It is possible to change the prompt itself, so that's something we might have to look into.
created_at: 2025-03-26T12:37:58Z
author_association: MEMBER
diff_hunk: @@ -177,6 +184,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
       clearLine(repl.output);
       moveCursor(repl.output, 0, -rows);
       inputPreview = null;
+      // If pressing enter on some history line,
+      // The next preview should not be generated
+      if ((key.name === 'return' || key.name === 'enter') && !key.meta && repl.historyIndex !== -1) {
+        justExecutedMultilineCommand = true;
+      }
path: lib/internal/repl/utils.js
user: login: BridgeAR
body: I believe this should be moved to where `showPreview()` is called (preventing it from being called again in the first place). That way it seems like it's better decoupled, or is it mandatory to call `showPreview()` again due to some other reason?
created_at: 2025-03-26T12:42:07Z
author_association: MEMBER
diff_hunk: @@ -633,6 +633,191 @@ const tests = [
     ],
     clean: false
   },
+  {
+    // Test that the multiline history is correctly navigated and it can be edited
+    env: { NODE_REPL_HISTORY: defaultHistoryPath },
+    skip: !process.features.inspector,
+    test: [
+      'let a = ``',
+      ENTER,
+      'a = `I am a multiline strong',
+      ENTER,
+      'which ends here`',
+      ENTER,
+      UP,
+      // press LEFT 19 times to reach the typo
+      ...Array(19).fill(LEFT),
+      BACKSPACE,
+      'i',
+      ENTER,
+    ],
+    expected: [
+      prompt, ...'let a = ``',
+      'undefined\n',
+      prompt, ...'a = `I am a multiline strong',
+      '| ',
+      ...'which ends here`',
+      "'I am a multiline strong\\nwhich ends here'\n",
+      prompt,
+      `${prompt}a = \`I am a multiline strong`,
+      `\n| which ends here\``,
+      `${prompt}a = \`I am a multiline strong`,
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline strng`,
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline string`,
+      `\n| which ends here\``,
+
+      `${prompt}a = \`I am a multiline string`,
+      `\n| which ends here\``,
+      "'I am a multiline string\\nwhich ends here'\n",
path: test/parallel/test-repl-history-navigation.js
user: login: BridgeAR
body: Could you add comments where ENTER / etc was pressed? I have the feeling the output is happening too often (without creating a visible difference for users), I might be mistaken, though.
created_at: 2025-03-26T12:50:35Z
author_association: MEMBER
diff_hunk: @@ -949,7 +951,32 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (cmd && StringPrototypeIndexOf(cmd, '\n') !== -1) { // If you are editing a multiline command
path: lib/repl.js
user: login: BridgeAR
body: ```suggestion
      if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
```
created_at: 2025-03-26T12:55:11Z
author_association: MEMBER
diff_hunk: @@ -949,7 +951,32 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (cmd && StringPrototypeIndexOf(cmd, '\n') !== -1) { // If you are editing a multiline command
+        self.history[0] = StringPrototypeReplaceAll(cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        ArrayPrototypePop(lines);
+        // And replace them with the single command split by '\r'
+        ArrayPrototypePush(lines, cmd);
path: lib/repl.js
user: login: BridgeAR
body: I am not certain why the last entry is replaced here. Could that be added as comment?

```suggestion
        lines[lines.length - 1] = cmd;
```
created_at: 2025-03-26T13:20:07Z
author_association: MEMBER
diff_hunk: @@ -949,7 +951,32 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (cmd && StringPrototypeIndexOf(cmd, '\n') !== -1) { // If you are editing a multiline command
+        self.history[0] = StringPrototypeReplaceAll(cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        ArrayPrototypePop(lines);
+        // And replace them with the single command split by '\r'
+        ArrayPrototypePush(lines, cmd);
+        const newHistoryLine = ArrayPrototypeJoin(lines, '\r');
+        if (self.history[0] !== newHistoryLine) {
path: lib/repl.js
user: login: BridgeAR
body: I believe this prevents duplicate entries?

Since we already have such functionality somewhere, should that not be adjusted to just also be able to handle multiline input?
created_at: 2025-03-26T13:23:19Z
author_association: MEMBER
diff_hunk: @@ -1024,9 +1095,19 @@ class Interface extends InterfaceConstructor {
    *   }}
    */
   getCursorPos() {
-    const strBeforeCursor =
-      this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
-    return this[kGetDisplayPos](strBeforeCursor);
+    // Handle multiline input by accounting for "| " prefixes on continuation lines
+    const lineUptoCursor = StringPrototypeSlice(this.line, 0, this.cursor);
+    const lines = StringPrototypeSplit(lineUptoCursor, '\n');
+
+    // First line uses normal prompt, subsequent lines use "| " (2 chars)
+    let displayText = this[kPrompt] + lines[0];
+
+    // Add continuation lines with their prefixes
+    for (let i = 1; i < lines.length; i++) {
+      displayText += '\n| ' + lines[i];
+    }
+
+    return this[kGetDisplayPos](displayText);
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: I don't think this should be changed. Instead, if `_getDisplayPos()` would handle this properly, it would just work in all cases. I believe I commented in another file almost the same.
created_at: 2025-03-26T13:30:19Z
author_association: MEMBER
diff_hunk: @@ -449,8 +454,15 @@ class Interface extends InterfaceConstructor {
 
     // If the trimmed line is empty then return the line
     if (StringPrototypeTrim(this.line).length === 0) return this.line;
-
-    if (this.history.length === 0 || this.history[0] !== this.line) {
+    const normalizedLine = StringPrototypeReplaceAll(this.line, '\n', '\r');
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: I understand that this works fine, I just fear it might make it more difficult to adjust the methods further. As developer, I would be surprised about the behavior change for \r and \n. Is there a possibility that we abstract the conversion somewhere in a central point that we know it will only ever happen in exactly two places and changing other code would not need to know about it?
created_at: 2025-03-26T13:32:46Z
author_association: MEMBER
diff_hunk: @@ -449,8 +454,15 @@ class Interface extends InterfaceConstructor {
 
     // If the trimmed line is empty then return the line
     if (StringPrototypeTrim(this.line).length === 0) return this.line;
-
-    if (this.history.length === 0 || this.history[0] !== this.line) {
+    const normalizedLine = StringPrototypeReplaceAll(this.line, '\n', '\r');
+
+    if (this.history.length === 0 || this.history[0] !== normalizedLine) {
+      if (this.lastCommandErrored && this.historyIndex === 0) {
+        // If the last command errored, remove it from history.
+        // The user is issuing a new command starting from the errored command,
+        // Hopefully with the fix
+        ArrayPrototypeShift(this.history);
+      }
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: I definitely like the change ❤️

I believe it's worth noting it in the repl docs as changes part and we should probably generally check if the documentation needs an update with these changes.
created_at: 2025-03-26T13:36:58Z
author_association: MEMBER
diff_hunk: @@ -498,8 +510,22 @@ class Interface extends InterfaceConstructor {
     // erase data
     clearScreenDown(this.output);
 
-    // Write the prompt and the current buffer content.
-    this[kWriteToOutput](line);
+    // Check if this is a multiline entry
+    const lines = StringPrototypeSplit(this.line, '\n');
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Instead of checking this, could we have a state instead? I saw it's checked in many places, so having a symbol property that would know if we handle a multiline string or not would AFAIK just reduce the code overhead.
created_at: 2025-03-26T13:39:34Z
author_association: MEMBER
diff_hunk: @@ -928,6 +954,29 @@ class Interface extends InterfaceConstructor {
     this[kRefreshLine]();
   }
 
+  [kMoveDownOrHistoryNext]() {
+    const { cols, rows } = this.getCursorPos();
+    const splitLine = StringPrototypeSplit(this.line, '\n');
+    if (!this.historyIndex && rows === splitLine.length) {
+      return;
+    }
+    // Go to the next history only if the cursor is in the first line of the multiline input.
+    // Otherwise treat the "arrow down" as a movement to the next row.
+    if (splitLine.length > 1 && rows < splitLine.length - 1) {
+      const currentLine = splitLine[rows];
+      const nextLine = splitLine[rows + 1];
+      const amountToMove = (cols > nextLine.length + 1) ?
+        currentLine.length - cols + nextLine.length + 3 :
+        currentLine.length + 1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: The plus and minus parts are difficult to follow. Could you explain those please? :)
created_at: 2025-03-26T13:41:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,198 @@
+'use strict';
+
+// Flags: --expose-internals
+
+const common = require('../common');
+
+const assert = require('assert');
+const repl = require('internal/repl');
+const stream = require('stream');
+const fs = require('fs');
+
+class ActionStream extends stream.Stream {
+  run(data) {
+    const _iter = data[Symbol.iterator]();
+    const doAction = () => {
+      const next = _iter.next();
+      if (next.done) {
+        // Close the repl. Note that it must have a clean prompt to do so.
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
+        return;
+      }
+      const action = next.value;
+
+      if (typeof action === 'object') {
+        this.emit('keypress', '', action);
+      }
+      setImmediate(doAction);
+    };
+    doAction();
+  }
+  write(chunk) {
+    const chunkLines = chunk.toString('utf8').split('\n');
+    this.lines[this.lines.length - 1] += chunkLines[0];
+    if (chunkLines.length > 1) {
+      this.lines.push(...chunkLines.slice(1));
+    }
+    this.emit('line', this.lines[this.lines.length - 1]);
+    return true;
+  }
+  resume() {}
+  pause() {}
+}
+ActionStream.prototype.readable = true;
+
+function cleanupTmpFile() {
+  try {
+    // Write over the file, clearing any history
+    fs.writeFileSync(defaultHistoryPath, '');
+  } catch (err) {
+    if (err.code === 'ENOENT') return true;
+    throw err;
+  }
+  return true;
+}
+
+const tmpdir = require('../common/tmpdir');
+tmpdir.refresh();
+const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
+
+{
+  cleanupTmpFile();
+  // It moves the cursor at the right places
+  const checkResults = common.mustSucceed((r) => {
+    r.write('let str = `');
+    r.input.run([{ name: 'enter' }]);
+    r.write('111');
+    r.input.run([{ name: 'enter' }]);
+    r.write('22222222222222');
+    r.input.run([{ name: 'enter' }]);
+    r.write('3`');
+    r.input.run([{ name: 'enter' }]);
+    r.input.run([{ name: 'up' }]);
+    assert.strictEqual(r.cursor, 33);
path: test/parallel/test-repl-multiline-navigation.js
user: login: puskin
body: the cursor is... emh.. the cursor 😄  In this test I am checking that the actual cursor (the keyboard one, when you write) is exactly in the right position!  
This is to make sure, for example, that when you are at the end of a long line and you press down into a shorter line, the cursor ends up at the end of the short line, instead of at some random position
created_at: 2025-03-26T13:56:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -155,8 +155,15 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
 
   let escaped = null;
 
+  let justExecutedMultilineCommand = false;
+
   function getPreviewPos() {
     const displayPos = repl._getDisplayPos(`${repl.getPrompt()}${repl.line}`);
+    // If the line is a multi line
+    if (StringPrototypeIndexOf(repl.line, '\n') !== -1) {
+      // This is to consider the additional "| " that prefixes the line
+      displayPos.cols += 2;
path: lib/internal/repl/utils.js
user: login: puskin
body: ~~About moving it into `repl._getDisplayPos()`: it does not work, it ALWAYS shifts the cursor 2 chars on the right. This is logic that is implemented only when the preview is active~~  
I have done it in `kGetDisplayPos`, MUCH cleaner indeed

About the possibility for the user to change the pipe into something else: it is not currently a thing, but we could implement it in the future
created_at: 2025-03-26T14:04:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -177,6 +184,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
       clearLine(repl.output);
       moveCursor(repl.output, 0, -rows);
       inputPreview = null;
+      // If pressing enter on some history line,
+      // The next preview should not be generated
+      if ((key.name === 'return' || key.name === 'enter') && !key.meta && repl.historyIndex !== -1) {
+        justExecutedMultilineCommand = true;
+      }
path: lib/internal/repl/utils.js
user: login: puskin
body: No, `showPreview()` should not be called again, but if I move this code anywhere else I lose control of the `key` I am checking, which is essential to determine when the user pressed enter while visiting an history entry. Only in that case the `showPreview()` should be interrupted
created_at: 2025-03-26T14:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -949,7 +951,32 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (cmd && StringPrototypeIndexOf(cmd, '\n') !== -1) { // If you are editing a multiline command
+        self.history[0] = StringPrototypeReplaceAll(cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        ArrayPrototypePop(lines);
+        // And replace them with the single command split by '\r'
+        ArrayPrototypePush(lines, cmd);
+        const newHistoryLine = ArrayPrototypeJoin(lines, '\r');
+        if (self.history[0] !== newHistoryLine) {
path: lib/repl.js
user: login: puskin
body: That's what I was trying in the beginning, but the problem is that each line ending with a `\n` is added to the history independently, calling `[kAddHistory]()`. The `reconstruction` of the history from a "bunch of single lines" to an actual multiline is done in this highlighted function
created_at: 2025-03-26T14:54:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -949,7 +951,32 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (cmd && StringPrototypeIndexOf(cmd, '\n') !== -1) { // If you are editing a multiline command
+        self.history[0] = StringPrototypeReplaceAll(cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        ArrayPrototypePop(lines);
+        // And replace them with the single command split by '\r'
+        ArrayPrototypePush(lines, cmd);
+        const newHistoryLine = ArrayPrototypeJoin(lines, '\r');
+        if (self.history[0] !== newHistoryLine) {
path: lib/repl.js
user: login: BridgeAR
body: kAddHistory should likely be part of the repl and not of readline (or we should expose the API regularly on readline. I am just not sure if that is really something that belongs to readline).

There is definitely need for clearer separation of the two modules. While I understand that it's out of scope for the PR.
created_at: 2025-04-03T09:15:32Z
author_association: MEMBER
diff_hunk: @@ -415,6 +416,11 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  setLine(line) {
+    this.line = line;
+    this.isMultiline = StringPrototypeIndexOf(line, '\n') !== -1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Adding `isMultiline` as property on the REPL exposes this to users and it becomes an official API that way. I don't think we should do that for now. Can you please use an internal symbol instead?
The same applies to `setLine()` and `(de)normalizeHistoryLineEndings()`.
created_at: 2025-04-03T09:19:38Z
author_association: MEMBER
diff_hunk: @@ -962,14 +975,14 @@ class Interface extends InterfaceConstructor {
     }
     // Go to the next history only if the cursor is in the first line of the multiline input.
     // Otherwise treat the "arrow down" as a movement to the next row.
-    if (splitLine.length > 1 && rows < splitLine.length - 1) {
+    if (this.isMultiline && rows < splitLine.length - 1) {
       const currentLine = splitLine[rows];
       const nextLine = splitLine[rows + 1];
+      // If I am moving down and the current line is longer than the next line
       const amountToMove = (cols > nextLine.length + 1) ?
-        currentLine.length - cols + nextLine.length + 3 :
-        currentLine.length + 1;
-      // Go to the same position on the next line, or the end of the next line
-      // If the current position does not exist in the next line.
+        currentLine.length - cols + nextLine.length + 3 : // Move to the end of the current line
+        // + 3 chars to account for the "| " prefix
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: The +3 is a bit confusing for me since "| " has a length of two. I guess one is actually something along the lines of 'one character after x'?
created_at: 2025-04-03T09:23:38Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1070,58 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Note: I think this is fine for the REPL while it's always faster to directly iterate over the string instead. Worst case is pretty much an outer loop with an if where you slice the part out of the string. That way there is no additional memory needed besides a few pointers and the state handling.
created_at: 2025-04-03T09:28:59Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1070,58 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    // Determine if this is a prompt string
+    const isPrompt = str === this[kPrompt] || !StringPrototypeStartsWith(str, this[kPrompt]);
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Can we rely upon that? What would for example happen if the prompt is set to an empty string?
created_at: 2025-04-03T09:29:58Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1070,58 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    // Determine if this is a prompt string
+    const isPrompt = str === this[kPrompt] || !StringPrototypeStartsWith(str, this[kPrompt]);
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && !isPrompt && this.isMultiline) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += 2; // Account for "| " prefix
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Is the prefix definitely always `'| '` or can we dynamically change that somewhere?
created_at: 2025-04-03T09:33:12Z
author_association: MEMBER
diff_hunk: @@ -59,7 +59,11 @@ const defaultHistoryPath = tmpdir.resolve('.node_repl_history');
 
 {
   cleanupTmpFile();
-  // It moves the cursor at the right places
+  // It moves the cursor at the right places.
+  // This test make sure that all the movements between lines are making the cursor
+  // Move to the right place.
+  // For example, when the cursor is at the end of a long line and the user presses
+  // the down key, the cursor should move to the end of the next line, if shorter.
path: test/parallel/test-repl-multiline-navigation.js
user: login: BridgeAR
body: `Make sure the cursor is at the right places.
If the cursor is at the end of a long line and the down key is pressed, move the cursor to the end of the next line, if shorter.`
created_at: 2025-04-03T10:02:32Z
author_association: MEMBER
diff_hunk: @@ -415,6 +423,11 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  [kSetLine](line) {
+    this.line = line;
+    this[kIsMultiline] = StringPrototypeIndexOf(line, '\n') !== -1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: ```suggestion
    this[kIsMultiline] = StringPrototypeIncludes(line, '\n');
```
created_at: 2025-04-03T12:48:44Z
author_association: MEMBER
diff_hunk: @@ -1216,10 +1213,7 @@ REPLServer.prototype.resetContext = function() {
 REPLServer.prototype.displayPrompt = function(preserveCursor) {
   let prompt = this._initialPrompt;
   if (this[kBufferedCommandSymbol].length) {
-    prompt = '|';
-    const len = this.lines.level.length ? this.lines.level.length - 1 : 0;
-    const levelInd = StringPrototypeRepeat('..', len);
-    prompt += levelInd + ' ';
path: lib/repl.js
user: login: BridgeAR
body: Should we indent the line with spaces in this case? I guess it is actually handled differently now and the removal makes things easier and is correct therefore?
created_at: 2025-04-03T12:53:02Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
+        offset = 0;
       } else {
-        // width === 2
-        if ((offset + 1) % col === 0) {
-          offset++;
-        }
-        offset += 2;
+        // For the last line
+        offset = lineOffset;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Should we just keep offset as single variable? That way the else case would not be needed and the offset is in the end a single one any way :)
created_at: 2025-04-03T12:55:38Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Is this not just always +1 line?
I believe it should not be a calculation.
created_at: 2025-04-03T12:56:26Z
author_association: MEMBER
diff_hunk: @@ -949,7 +950,30 @@ function REPLServer(prompt,
           self.displayPrompt();
           return;
         }
+      }
+
+      // In the next two if blocks, we do not use os.EOL instead of '\n'
+      // because on Windows it is '\r\n'
+      if (StringPrototypeIncludes(cmd, '\n')) { // If you are editing a multiline command
+        self.history[0] = self[kNormalizeHistoryLineEndings](cmd, '\n', '\r');
+      } else if (self[kBufferedCommandSymbol]) { // If a new multiline command was entered
+        // Remove the first N lines from the self.history array
+        // where N is the number of lines in the buffered command
+
+        const lines = StringPrototypeSplit(self[kBufferedCommandSymbol], '\n');
+        self.history = ArrayPrototypeSlice(self.history, lines.length);
+        lines[lines.length - 1] = cmd;
+        const newHistoryLine = ArrayPrototypeJoin(lines, '\r');
+        if (self.history[0] !== newHistoryLine) {
+          ArrayPrototypeUnshift(self.history, newHistoryLine);
+        }
+      }
+
+      if (e) {
         self._domain.emit('error', e.err || e);
+        self.lastCommandErrored = true;
+      } else {
+        self.lastCommandErrored = false;
path: lib/repl.js
user: login: BridgeAR
body: Should this state be moved to the top of the method? And we would need an internal symbol or it's public API.
created_at: 2025-04-03T13:03:29Z
author_association: MEMBER
diff_hunk: @@ -177,6 +184,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
       clearLine(repl.output);
       moveCursor(repl.output, 0, -rows);
       inputPreview = null;
+      // If pressing enter on some history line,
+      // The next preview should not be generated
+      if ((key.name === 'return' || key.name === 'enter') && !key.meta && repl.historyIndex !== -1) {
+        justExecutedMultilineCommand = true;
+      }
path: lib/internal/repl/utils.js
user: login: BridgeAR
body: Can't we just use `kIsMultiline` in the other method and this method needs no change at all?
created_at: 2025-04-03T13:08:59Z
author_association: MEMBER
diff_hunk: @@ -441,6 +459,16 @@ class Interface extends InterfaceConstructor {
     }
   }
 
+  // Convert newlines to a consistent format for history storage
+  [kNormalizeHistoryLineEndings](line) {
+    return StringPrototypeReplaceAll(line, '\n', '\r');
+  }
+
+  // Convert newlines back to the format expected by the UI
+  [kDenormalizeHistoryLineEndings](line) {
+    return StringPrototypeReplaceAll(line, '\r', '\n');
+  }
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: What about consolidating the methods by accepting two more arguments that would indicate the changes arguments?

That way we only need a single method and we can use default values for the normalization and just denormalization uses the args? I understand if you rather want it explicit though, so it's just a suggestion.
created_at: 2025-04-03T13:11:44Z
author_association: MEMBER
diff_hunk: @@ -1216,10 +1213,7 @@ REPLServer.prototype.resetContext = function() {
 REPLServer.prototype.displayPrompt = function(preserveCursor) {
   let prompt = this._initialPrompt;
   if (this[kBufferedCommandSymbol].length) {
-    prompt = '|';
-    const len = this.lines.level.length ? this.lines.level.length - 1 : 0;
-    const levelInd = StringPrototypeRepeat('..', len);
-    prompt += levelInd + ' ';
path: lib/repl.js
user: login: puskin
body: I see it more precise and correct now, this code should not play any part anymore, as far as I can tell
created_at: 2025-04-03T13:22:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
path: lib/internal/readline/interface.js
user: login: puskin
body: not according to some automated tests which are breaking :) 

<img width="264" alt="image" src="https://github.com/user-attachments/assets/73f16401-5819-4ad7-bbd4-a9e7ccc757b6" />

and I guess it makes sense, if we consider how the test is composed:

```js
// Multi-line prompt cursor position
{
  const [rli, fi] = getInterface({
    terminal: true,
    prompt: '\nfilledline\nwraping text\n> '
  });
  fi.columns = 10;
  fi.emit('data', 't');
  assertCursorRowsAndCols(rli, 4, 3);
  rli.close();
}
```
created_at: 2025-04-03T13:36:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: Ahh, it is actually moved code.
Looking at the change again, would it not be possible to only add this part:

```
if (i > 0 && this[kIsMultiline]) {
  offset += kMultilinePrompt.description.length;
}
```

I believe that should actually do the trick?
created_at: 2025-04-03T13:50:49Z
author_association: MEMBER
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
path: lib/internal/readline/interface.js
user: login: puskin
body: looking at the log I pasted earlier, we can have `offset: 3` and `col: Infinity`.  
If I apply your suggestion it would always add `2`, while with the current code it would add `3/Infinity = 0 || 1`, so `1`
created_at: 2025-04-03T14:10:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1058,31 +1078,55 @@ class Interface extends InterfaceConstructor {
     const col = this.columns;
     let rows = 0;
     str = stripVTControlCharacters(str);
-    for (const char of new SafeStringIterator(str)) {
-      if (char === '\n') {
-        // Rows must be incremented by 1 even if offset = 0 or col = +Infinity.
-        rows += MathCeil(offset / col) || 1;
-        offset = 0;
-        continue;
+
+    // Split the string into lines for better handling of multiline scenarios
+    const lines = StringPrototypeSplit(str, '\n');
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      let lineOffset = 0;
+
+      // Only add prefix offset for continuation lines in user input (not prompts)
+      if (i > 0 && this[kIsMultiline]) {
+        // When we're calculating the cursor position, we need special handling
+        // for line boundaries to make navigation smooth
+        lineOffset += kMultilinePrompt.description.length;
       }
-      // Tabs must be aligned by an offset of the tab size.
-      if (char === '\t') {
-        offset += this.tabSize - (offset % this.tabSize);
-        continue;
+
+      // Process each character in the line
+      for (const char of new SafeStringIterator(line)) {
+        // Tabs must be aligned by an offset of the tab size
+        if (char === '\t') {
+          lineOffset += this.tabSize - (lineOffset % this.tabSize);
+          continue;
+        }
+
+        const width = getStringWidth(char, false /* stripVTControlCharacters */);
+        if (width === 0 || width === 1) {
+          lineOffset += width;
+        } else {
+          // width === 2
+          if ((lineOffset + 1) % col === 0) {
+            lineOffset++;
+          }
+          lineOffset += 2;
+        }
       }
-      const width = getStringWidth(char, false /* stripVTControlCharacters */);
-      if (width === 0 || width === 1) {
-        offset += width;
+
+      // Add rows for completed lines
+      if (i < lines.length - 1) {
+        // For all lines except the last one
+        rows += MathCeil(lineOffset / col) || 1;
path: lib/internal/readline/interface.js
user: login: BridgeAR
body: I pushed a commit to resolve this
created_at: 2025-04-03T16:41:14Z
author_association: MEMBER
diff_hunk: @@ -177,6 +184,11 @@ function setupPreview(repl, contextSymbol, bufferSymbol, active) {
       clearLine(repl.output);
       moveCursor(repl.output, 0, -rows);
       inputPreview = null;
+      // If pressing enter on some history line,
+      // The next preview should not be generated
+      if ((key.name === 'return' || key.name === 'enter') && !key.meta && repl.historyIndex !== -1) {
+        justExecutedMultilineCommand = true;
+      }
path: lib/internal/repl/utils.js
user: login: BridgeAR
body: I pushed a commit to resolve this
created_at: 2025-04-03T16:41:38Z
author_association: MEMBER
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: ljharb
body: is the `\r` not needed here for windows users?
created_at: 2025-04-04T20:02:03Z
author_association: MEMBER
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: BridgeAR
body: I talked with @puskin94 about it and we define what we write into the file. That is why it's fine to keep it as `\n`.
created_at: 2025-04-04T20:08:15Z
author_association: MEMBER
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: BridgeAR
body: Seems like that is not true though, or it fails as a different point.
Windows tests are all failing.
created_at: 2025-04-05T08:52:00Z
author_association: MEMBER
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: puskin
body: Investigating. When I did my manual tests on windows I didn't see any issue. Or something broke with the most recent commits or I didn't manually test some specific cases covered by the automated tests
created_at: 2025-04-05T09:05:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -97,7 +97,7 @@ function setupHistory(repl, historyPath, ready) {
     }
 
     if (data) {
-      repl.history = RegExpPrototypeSymbolSplit(/[\n\r]+/, data, repl.historySize);
+      repl.history = RegExpPrototypeSymbolSplit(/\n+/, data, repl.historySize);
path: lib/internal/repl/history.js
user: login: puskin
body: it turns out it was either of those 😄 
I never changed the `flushHistory` function in the history file, that means it always joined the history lines before saving them in the history file with `os.EOL`, which is `\n\r` on windows, but `\n` anywhere else.
Normalized it to be just `\n`
created_at: 2025-04-05T21:17:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,21 +30,19 @@ class ActionStream extends stream.Stream {
       const next = _iter.next();
       if (next.done) {
         // Close the repl. Note that it must have a clean prompt to do so.
-        setImmediate(() => {
-          this.emit('keypress', '', { ctrl: true, name: 'd' });
-        });
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
path: test/parallel/test-repl-persistent-history.js
user: login: BridgeAR
body: I guess keeping `setImmediate()` could be kept? I would rather keep it to pretend the operations are handled async.
The same applies to the other places.
created_at: 2025-04-05T22:36:31Z
author_association: MEMBER
diff_hunk: @@ -31,21 +30,19 @@ class ActionStream extends stream.Stream {
       const next = _iter.next();
       if (next.done) {
         // Close the repl. Note that it must have a clean prompt to do so.
-        setImmediate(() => {
-          this.emit('keypress', '', { ctrl: true, name: 'd' });
-        });
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
         return;
       }
       const action = next.value;
 
       if (typeof action === 'object') {
         this.emit('keypress', '', action);
       } else {
-        this.emit('data', `${action}\n`);
+        this.emit('data', `${action}`);
path: test/parallel/test-repl-persistent-history.js
user: login: BridgeAR
body: I guess we could directly pass through action? Good find that the \n is obsolete!

```suggestion
        this.emit('data', action);
```
created_at: 2025-04-05T22:37:05Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
path: lib/internal/repl/history.js
user: login: BridgeAR
body: How would the history be handled in case the file already contains `\r\n`? I fear existing history files on windows would break.
created_at: 2025-04-05T22:39:32Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
path: lib/internal/repl/history.js
user: login: puskin
body: I just tested it, this is how it behaves:

if you write history with the old node and you open it with the new node, this is how it looks like:

![image](https://github.com/user-attachments/assets/f01fefb4-beac-4bc4-99e5-43f7612e4e3c)

aka, it sees a new additional line at the end of each line.

if you go back tho to the old node, the history is displayed correctly, without new lines.

To be honest, I don't see it too bad
created_at: 2025-04-06T07:11:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,21 +30,19 @@ class ActionStream extends stream.Stream {
       const next = _iter.next();
       if (next.done) {
         // Close the repl. Note that it must have a clean prompt to do so.
-        setImmediate(() => {
-          this.emit('keypress', '', { ctrl: true, name: 'd' });
-        });
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
path: test/parallel/test-repl-persistent-history.js
user: login: puskin
body: I did it this way because all the other tests with similar structure don't have the setImmediate, I did it for consistency.  
Happy to keep it here tho, I don't have strong opinions about this
created_at: 2025-04-06T07:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,21 +30,19 @@ class ActionStream extends stream.Stream {
       const next = _iter.next();
       if (next.done) {
         // Close the repl. Note that it must have a clean prompt to do so.
-        setImmediate(() => {
-          this.emit('keypress', '', { ctrl: true, name: 'd' });
-        });
+        this.emit('keypress', '', { ctrl: true, name: 'd' });
path: test/parallel/test-repl-persistent-history.js
user: login: BridgeAR
body: I think it might be nice to have some that effectively work async. That way we check for sync and async behavior properly.
created_at: 2025-04-08T15:02:59Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
path: lib/internal/repl/history.js
user: login: BridgeAR
body: I believe we could optimize this away. As in: if it's just a single extra line, it should just be skipped. I believe a user can not really create such input at all.
created_at: 2025-04-08T15:14:43Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
path: lib/internal/repl/history.js
user: login: BridgeAR
body: @puskin94 did you have a look at this one again? :)
I tried to look at it in the code but I am uncertain if it's still the case or not and I currently have no Windows for testing.
created_at: 2025-04-11T22:50:29Z
author_association: MEMBER
diff_hunk: @@ -141,7 +141,7 @@ function setupHistory(repl, historyPath, ready) {
       return;
     }
     writing = true;
-    const historyData = ArrayPrototypeJoin(repl.history, os.EOL);
+    const historyData = ArrayPrototypeJoin(repl.history, '\n');
path: lib/internal/repl/history.js
user: login: puskin
body: Yessir, it is fixed! I had to change the regex from `/\n+/` to `/\r?\n+/`

https://github.com/nodejs/node/pull/57400/files#diff-6e4d0ac8c3464727bace437f66320b66535a6d3663100c71be558616aae74ae4R100
created_at: 2025-04-12T05:55:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-12T11:36:48Z
body: ## [Codecov](https://app.codecov.io/gh/nodejs/node/pull/57400?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs) Report
Attention: Patch coverage is `96.62921%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 90.24%. Comparing base [(`27f98c3`)](https://app.codecov.io/gh/nodejs/node/commit/27f98c3cd1cee93efb28efea11d59e51ba9be4b9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs) to head [(`cab0f16`)](https://app.codecov.io/gh/nodejs/node/commit/cab0f16a71996ca99fc79ba0d75acccba720bd49?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs).
> Report is 555 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/nodejs/node/pull/57400?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs) | Patch % | Lines |
|---|---|---|
| [lib/internal/readline/interface.js](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&filepath=lib%2Finternal%2Freadline%2Finterface.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs#diff-bGliL2ludGVybmFsL3JlYWRsaW5lL2ludGVyZmFjZS5qcw==) | 95.38% | [4 Missing and 2 partials :warning: ](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main   #57400      +/-   ##
==========================================
+ Coverage   90.20%   90.24%   +0.03%     
==========================================
  Files         630      630              
  Lines      185307   185613     +306     
  Branches    36269    36400     +131     
==========================================
+ Hits       167162   167507     +345     
+ Misses      11084    10994      -90     
- Partials     7061     7112      +51     
```

| [Files with missing lines](https://app.codecov.io/gh/nodejs/node/pull/57400?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs) | Coverage Δ | |
|---|---|---|
| [lib/internal/repl/history.js](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&filepath=lib%2Finternal%2Frepl%2Fhistory.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs#diff-bGliL2ludGVybmFsL3JlcGwvaGlzdG9yeS5qcw==) | `88.46% <100.00%> (+2.19%)` | :arrow_up: |
| [lib/internal/repl/utils.js](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&filepath=lib%2Finternal%2Frepl%2Futils.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs#diff-bGliL2ludGVybmFsL3JlcGwvdXRpbHMuanM=) | `96.79% <100.00%> (+0.52%)` | :arrow_up: |
| [lib/repl.js](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&filepath=lib%2Frepl.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs#diff-bGliL3JlcGwuanM=) | `95.03% <100.00%> (+0.13%)` | :arrow_up: |
| [lib/internal/readline/interface.js](https://app.codecov.io/gh/nodejs/node/pull/57400?src=pr&el=tree&filepath=lib%2Finternal%2Freadline%2Finterface.js&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs#diff-bGliL2ludGVybmFsL3JlYWRsaW5lL2ludGVyZmFjZS5qcw==) | `96.76% <95.38%> (-0.19%)` | :arrow_down: |

... and [145 files with indirect coverage changes](https://app.codecov.io/gh/nodejs/node/pull/57400/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nodejs)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: aduh95
created_at: 2025-03-16T23:29:30Z
body: /cc @nodejs/repl 
author_association: CONTRIBUTOR
user: login: puskin
created_at: 2025-03-17T18:03:22Z
body: @BridgeAR you, being the requestor, maybe want to give this a shot 😄 
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-04T19:44:21Z
body: <details><summary>Failed to start CI</summary><pre>   ⚠  Commits were pushed since the last approving review:
   ⚠  - repl: add support for multiline history
   ⚠  - repl: do not use the custom parser but split lines with \r
   ⚠  - repl: replace ... with | and navigate multiline history with up / down
   ⚠  - repl: recover lines with syntax errors in history
   ⚠  - repl: consider removeHistoryDuplicates when saving multiline history
   ⚠  - repl: add pipe char for browsing multiline history
   ⚠  - repl: update the cursor logic in kGetDisplayPos instead of getCursorPos
   ⚠  - fixup! simplify implementation
   ⚠  - fixup! simplify code further
   ⚠  - fixup! linter
   ✘  Refusing to run CI on potentially unsafe PR</pre><a href='https://github.com/nodejs/node/actions/runs/14272963414'>https://github.com/nodejs/node/actions/runs/14272963414</a></details>
author_association: CONTRIBUTOR
user: login: nodejs-github-bot
created_at: 2025-04-04T19:52:29Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66034/
author_association: COLLABORATOR
user: login: nodejs-github-bot
created_at: 2025-04-04T23:51:10Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66038/
author_association: COLLABORATOR
user: login: nodejs-github-bot
created_at: 2025-04-05T07:48:50Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66040/
author_association: COLLABORATOR
user: login: ljharb
created_at: 2025-04-05T23:46:09Z
body: Since using an old version of node corrupts the history to split lines, i'd say it's major. If we can come up with a way to avoid that, then it'd be minor, *and* aggresively backportable, which would be ideal.
author_association: MEMBER
user: login: puskin
created_at: 2025-04-06T07:34:47Z
body: @ljharb 

`corrupts the history to split lines`: it is true, but you are not breaking anything that was existing **before** this PR, it is not breaking existing functionality, it is breaking new functionality. 
This might be a valid point or not, but I would be curious to know how many people are constantly switching node versions on a daily basis, because this would be the only case where this issue would arise.  
On the topic of "restoring" existing history:  
I (I had other people thinking with me 😄 ) spent quite some time thinking about how / if we could understand and differentiate which "history version" we are reading from the history file, but it is a lot more complicated than what it seems like. These are the options:

### Introducing a new history file format 
something new, a `.node_repl_history_v2`, so to speak, which would be started to be written as soon as this PR lands.

🟢  Pros: there is no complicated logic to determine which history you want to load. If you have new node, read the new history file with the new format, otherwise the old history file with the old node.
🔴 Cons: we need to maintain 2 different history files, with 2 different formats, with 2 specific logics in the codebase to port/understand the history when loading and saving.

### Introducing an additional "metadata" history file
something new, a `.node_repl_history_metadata`, so to speak, which would be started to be written as soon as this PR lands, which would only keep track where lines are to be treated as single lines or multilines.

🟢  Pros: there is no complicated logic to determine which history you want to load. If you have new node, read the new history "metadata" file and parse the history file accordingly, otherwise don't parse the "metadata" file and just read the history file.
🔴 Cons: we need to maintain 2 different files. Adding logic to handle the metadata which need to be updated when you load and save the file, it can become tricky really fast, and switching between old and new node would break as well.

### Parsing the history file and trying to restore multiline structures from the old history file
when the old node lands and people start using it, it could try to parse the history file and understand, line by line, if a group of lines would be a single multiline structure.

🟢  Pros: you only handle a single history file.
🔴 Cons: it sounds easy on paper, it is not 😄 this is actually my first implementation, which I executed using `acorn` to parse the history file, and it worked great!  
The only problem is that, reading line by line, the parsing in the lib was emitting syntax exceptions until it found the next line which was "completing the multiline structure", and then considered that a single multiline history. The issue tho is: what if there is a syntax exception in the history file because the user put it there? How does `acorn` know that the command is broken and it does not need to go to the next line to try to complete the multline command?  
It might be possible, but it would be `O(n^2)` complex, which is not great. (thanks @BridgeAR for this insight)
author_association: CONTRIBUTOR
user: login: ljharb
created_at: 2025-04-08T15:50:52Z
body: I was wondering if there's a way to avoid that parse by using unicode newlines instead of normal ones, such that older node handles it normally but newer node recognizes it as multiline input?
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-09T12:30:17Z
body: The https://github.com/nodejs/node/labels/notable-change label has been added by @BridgeAR.

Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the _Other Notable Changes_ section.

author_association: CONTRIBUTOR
user: login: BridgeAR
created_at: 2025-04-09T12:35:20Z
body: After looking into this again, I do not believe that it's possible to get the unicode line breaks to work in older Node.js versions. So the only way I believe is viable without renaming the file is to accept that switching to an older version removes the multilines for those history entries.
What we can do is to try to backport this to as many versions as possible.
author_association: MEMBER
user: login: nodejs-github-bot
created_at: 2025-04-12T10:26:00Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66212/ 💛 
author_association: COLLABORATOR
user: login: nodejs-github-bot
created_at: 2025-04-13T06:48:32Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66234/
author_association: COLLABORATOR
user: login: nodejs-github-bot
created_at: 2025-04-13T08:30:49Z
body: CI: https://ci.nodejs.org/job/node-test-pull-request/66236/
author_association: COLLABORATOR
user: login: nodejs-github-bot
created_at: 2025-04-13T11:58:03Z
body: Landed in 4a4aa58fa4a3736744f605abc70eb6c167aadf9d
author_association: COLLABORATOR
user: login: lpinca
created_at: 2025-04-14T13:41:38Z
body: It seems that `test/parallel/test-repl-multiline-navigation` is flaky. See https://ci.nodejs.org/job/node-test-commit-aix/56805/nodes=aix72-ppc64/testReport/junit/(root)/parallel/test_repl_multiline_navigation/

```
---
duration_ms: 324.926
exitcode: 1
severity: fail
stack: |-
  node:assert:95
    throw new AssertionError(obj);
    ^

  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  2 !== 1

      at /home/iojs/build/workspace/node-test-commit-aix/nodes/aix72-ppc64/test/parallel/test-repl-multiline-navigation.js:132:12
      at /home/iojs/build/workspace/node-test-commit-aix/nodes/aix72-ppc64/test/common/index.js:400:17
      at /home/iojs/build/workspace/node-test-commit-aix/nodes/aix72-ppc64/test/common/index.js:435:15
      at REPLServer.<anonymous> (node:internal/repl/history:120:9)
      at Object.onceWrapper (node:events:621:28)
      at REPLServer.emit (node:events:507:28)
      at REPLServer.emit (node:domain:489:12)
      at onwritten (node:internal/repl/history:156:14)
      at FSReqCallback.wrapper [as oncomplete] (node:fs:803:5)
      at FSReqCallback.callbackTrampoline (node:internal/async_hooks:130:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 2,
    expected: 1,
    operator: 'strictEqual'
  }

  Node.js v24.0.0-pre
...
```

Can you take a look?
author_association: MEMBER
user: login: puskin
created_at: 2025-04-15T07:15:06Z
body: @lpinca thanks for the report!
It should get fixed here: https://github.com/nodejs/node/pull/57874 😄 
author_association: CONTRIBUTOR