diff --git a/doc/api/readline.md b/doc/api/readline.md
index 4c64678fb467b2..7e7b06b756bcb2 100644
--- a/doc/api/readline.md
+++ b/doc/api/readline.md
@@ -1313,6 +1313,16 @@ const { createInterface } = require('readline');
Delete from the current position to the end of line |
|
+
+ Ctrl+Y |
+ Yank (Recall) the previously deleted text |
+ Only works with text deleted by Ctrl+U or Ctrl+K |
+
+
+ Meta+Y |
+ Cycle among previously deleted lines |
+ Only available when the last keystroke is Ctrl+Y |
+
Ctrl+A |
Go to start of line |
diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js
index 4ef4fe2ffa6846..5c9cb94ced5817 100644
--- a/lib/internal/readline/interface.js
+++ b/lib/internal/readline/interface.js
@@ -81,6 +81,9 @@ const kQuestionCancel = Symbol('kQuestionCancel');
// GNU readline library - keyseq-timeout is 500ms (default)
const ESCAPE_CODE_TIMEOUT = 500;
+// Max length of the kill ring
+const kMaxLengthOfKillRing = 32;
+
const kAddHistory = Symbol('_addHistory');
const kBeforeEdit = Symbol('_beforeEdit');
const kDecoder = Symbol('_decoder');
@@ -96,12 +99,15 @@ const kHistoryPrev = Symbol('_historyPrev');
const kInsertString = Symbol('_insertString');
const kLine = Symbol('_line');
const kLine_buffer = Symbol('_line_buffer');
+const kKillRing = Symbol('_killRing');
+const kKillRingCursor = Symbol('_killRingCursor');
const kMoveCursor = Symbol('_moveCursor');
const kNormalWrite = Symbol('_normalWrite');
const kOldPrompt = Symbol('_oldPrompt');
const kOnLine = Symbol('_onLine');
const kPreviousKey = Symbol('_previousKey');
const kPrompt = Symbol('_prompt');
+const kPushToKillRing = Symbol('_pushToKillRing');
const kPushToUndoStack = Symbol('_pushToUndoStack');
const kQuestionCallback = Symbol('_questionCallback');
const kRedo = Symbol('_redo');
@@ -118,6 +124,9 @@ const kUndoStack = Symbol('_undoStack');
const kWordLeft = Symbol('_wordLeft');
const kWordRight = Symbol('_wordRight');
const kWriteToOutput = Symbol('_writeToOutput');
+const kYank = Symbol('_yank');
+const kYanking = Symbol('_yanking');
+const kYankPop = Symbol('_yankPop');
function InterfaceConstructor(input, output, completer, terminal) {
this[kSawReturnAt] = 0;
@@ -211,6 +220,15 @@ function InterfaceConstructor(input, output, completer, terminal) {
this[kRedoStack] = [];
this.history = history;
this.historySize = historySize;
+
+ // The kill ring is a global list of blocks of text that were previously
+ // killed (deleted). If its size exceeds kMaxLengthOfKillRing, the oldest
+ // element will be removed to make room for the latest deletion. With kill
+ // ring, users are able to recall (yank) or cycle (yank pop) among previously
+ // killed texts, quite similar to the behavior of Emacs.
+ this[kKillRing] = [];
+ this[kKillRingCursor] = 0;
+
this.removeHistoryDuplicates = !!removeHistoryDuplicates;
this.crlfDelay = crlfDelay ?
MathMax(kMincrlfDelay, crlfDelay) :
@@ -606,10 +624,12 @@ class Interface extends InterfaceConstructor {
this.cursor += c.length;
this[kRefreshLine]();
} else {
+ const oldPos = this.getCursorPos();
this.line += c;
this.cursor += c.length;
+ const newPos = this.getCursorPos();
- if (this.getCursorPos().cols === 0) {
+ if (oldPos.rows < newPos.rows) {
this[kRefreshLine]();
} else {
this[kWriteToOutput](c);
@@ -792,17 +812,57 @@ 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.cursor = 0;
+ this[kPushToKillRing](del);
this[kRefreshLine]();
}
[kDeleteLineRight]() {
this[kBeforeEdit](this.line, this.cursor);
+ const del = StringPrototypeSlice(this.line, this.cursor);
this.line = StringPrototypeSlice(this.line, 0, this.cursor);
+ this[kPushToKillRing](del);
this[kRefreshLine]();
}
+ [kPushToKillRing](del) {
+ if (!del || del === this[kKillRing][0]) return;
+ ArrayPrototypeUnshift(this[kKillRing], del);
+ this[kKillRingCursor] = 0;
+ while (this[kKillRing].length > kMaxLengthOfKillRing)
+ ArrayPrototypePop(this[kKillRing]);
+ }
+
+ [kYank]() {
+ if (this[kKillRing].length > 0) {
+ this[kYanking] = true;
+ this[kInsertString](this[kKillRing][this[kKillRingCursor]]);
+ }
+ }
+
+ [kYankPop]() {
+ if (!this[kYanking]) {
+ return;
+ }
+ if (this[kKillRing].length > 1) {
+ const lastYank = this[kKillRing][this[kKillRingCursor]];
+ this[kKillRingCursor]++;
+ if (this[kKillRingCursor] >= this[kKillRing].length) {
+ this[kKillRingCursor] = 0;
+ }
+ const currentYank = this[kKillRing][this[kKillRingCursor]];
+ const head =
+ StringPrototypeSlice(this.line, 0, this.cursor - lastYank.length);
+ const tail =
+ StringPrototypeSlice(this.line, this.cursor);
+ this.line = head + currentYank + tail;
+ this.cursor = head.length + currentYank.length;
+ this[kRefreshLine]();
+ }
+ }
+
clearLine() {
this[kMoveCursor](+Infinity);
this[kWriteToOutput]('\r\n');
@@ -984,6 +1044,11 @@ class Interface extends InterfaceConstructor {
key = key || {};
this[kPreviousKey] = key;
+ if (!key.meta || key.name !== 'y') {
+ // Reset yanking state unless we are doing yank pop.
+ this[kYanking] = false;
+ }
+
// Activate or deactivate substring search.
if (
(key.name === 'up' || key.name === 'down') &&
@@ -1094,6 +1159,10 @@ class Interface extends InterfaceConstructor {
this[kHistoryPrev]();
break;
+ case 'y': // Yank killed string
+ this[kYank]();
+ break;
+
case 'z':
if (process.platform === 'win32') break;
if (this.listenerCount('SIGTSTP') > 0) {
@@ -1158,6 +1227,10 @@ class Interface extends InterfaceConstructor {
case 'backspace': // Delete backwards to a word boundary
this[kDeleteWordLeft]();
break;
+
+ case 'y': // Doing yank pop
+ this[kYankPop]();
+ break;
}
} else {
/* No modifier keys used */
diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js
index ff0c83efc9fef4..04bacf39309168 100644
--- a/test/parallel/test-readline-interface.js
+++ b/test/parallel/test-readline-interface.js
@@ -674,6 +674,77 @@ function assertCursorRowsAndCols(rli, rows, cols) {
rli.close();
}
+// yank
+{
+ const [rli, fi] = getInterface({ terminal: true, prompt: '' });
+ fi.emit('data', 'the quick brown fox');
+ assertCursorRowsAndCols(rli, 0, 19);
+
+ // Go to the start of the line
+ fi.emit('keypress', '.', { ctrl: true, name: 'a' });
+ // Move forward one char
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ // Delete the right part
+ fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
+ assertCursorRowsAndCols(rli, 0, 1);
+
+ // Yank
+ fi.emit('keypress', '.', { ctrl: true, name: 'y' });
+ assertCursorRowsAndCols(rli, 0, 19);
+
+ rli.on('line', common.mustCall((line) => {
+ assert.strictEqual(line, 'the quick brown fox');
+ }));
+
+ fi.emit('data', '\n');
+ rli.close();
+}
+
+// yank pop
+{
+ const [rli, fi] = getInterface({ terminal: true, prompt: '' });
+ fi.emit('data', 'the quick brown fox');
+ assertCursorRowsAndCols(rli, 0, 19);
+
+ // Go to the start of the line
+ fi.emit('keypress', '.', { ctrl: true, name: 'a' });
+ // Move forward one char
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ // Delete the right part
+ fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
+ assertCursorRowsAndCols(rli, 0, 1);
+ // Yank
+ fi.emit('keypress', '.', { ctrl: true, name: 'y' });
+ assertCursorRowsAndCols(rli, 0, 19);
+
+ // Go to the start of the line
+ fi.emit('keypress', '.', { ctrl: true, name: 'a' });
+ // Move forward four chars
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ fi.emit('keypress', '.', { ctrl: true, name: 'f' });
+ // Delete the right part
+ fi.emit('keypress', '.', { ctrl: true, shift: true, name: 'delete' });
+ assertCursorRowsAndCols(rli, 0, 4);
+ // Go to the start of the line
+ fi.emit('keypress', '.', { ctrl: true, name: 'a' });
+ assertCursorRowsAndCols(rli, 0, 0);
+
+ // Yank: 'quick brown fox|the '
+ fi.emit('keypress', '.', { ctrl: true, name: 'y' });
+ // Yank pop: 'he quick brown fox|the'
+ fi.emit('keypress', '.', { meta: true, name: 'y' });
+ assertCursorRowsAndCols(rli, 0, 18);
+
+ rli.on('line', common.mustCall((line) => {
+ assert.strictEqual(line, 'he quick brown foxthe ');
+ }));
+
+ fi.emit('data', '\n');
+ rli.close();
+}
+
// Close readline interface
{
const [rli, fi] = getInterface({ terminal: true, prompt: '' });