Skip to content

Commit 700207b

Browse files
committed
readline: avoid painting default prompt before prompt()
Track whether prompt() has been invoked and whether the configured prompt is still the documented default. Until then, skip the default prefix when redrawing the line or computing cursor position, fixing inconsistent appearance after keys that refresh the line (see issue #12606). Set the same flag when tab completion redraws the line so behavior matches prior releases. Document that callers should call prompt() from a line listener when they want a prompt on each new line (REPL already does). Refs: #12606 Assisted-by: Cursor IDE
1 parent 60f19bc commit 700207b

3 files changed

Lines changed: 102 additions & 5 deletions

File tree

doc/api/readline.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,10 @@ location at which to provide input.
321321
When called, `rl.prompt()` will resume the `input` stream if it has been
322322
paused.
323323

324+
To show the prompt on each new line of input, call `rl.prompt()` from your
325+
`'line'` event listener (the built-in REPL does this after evaluating each
326+
line).
327+
324328
If the `InterfaceConstructor` was created with `output` set to `null` or
325329
`undefined` the prompt is not written.
326330

@@ -710,6 +714,9 @@ added: v17.0.0
710714
to the history list duplicates an older one, this removes the older line
711715
from the list. **Default:** `false`.
712716
* `prompt` {string} The prompt string to use. **Default:** `'> '`.
717+
For TTY interfaces, the default prompt is not written when the line is
718+
redrawn until `rl.prompt()` has been called at least once (see
719+
[`rl.prompt()`][]). A non-default `prompt` is written on redraw as before.
713720
* `crlfDelay` {number} If the delay between `\r` and `\n` exceeds
714721
`crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate
715722
end-of-line input. `crlfDelay` will be coerced to a number no less than
@@ -975,6 +982,9 @@ changes:
975982
to the history list duplicates an older one, this removes the older line
976983
from the list. **Default:** `false`.
977984
* `prompt` {string} The prompt string to use. **Default:** `'> '`.
985+
For TTY interfaces, the default prompt is not written when the line is
986+
redrawn until `rl.prompt()` has been called at least once (see
987+
[`rl.prompt()`][]). A non-default `prompt` is written on redraw as before.
978988
* `crlfDelay` {number} If the delay between `\r` and `\n` exceeds
979989
`crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate
980990
end-of-line input. `crlfDelay` will be coerced to a number no less than
@@ -1492,4 +1502,5 @@ const { createInterface } = require('node:readline');
14921502
[`process.stdin`]: process.md#processstdin
14931503
[`process.stdout`]: process.md#processstdout
14941504
[`rl.close()`]: #rlclose
1505+
[`rl.prompt()`]: #rlpromptpreservecursor
14951506
[reading files]: #example-read-file-stream-line-by-line

lib/internal/readline/interface.js

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ const kMaxLengthOfKillRing = 32;
9999

100100
const kMultilinePrompt = Symbol('| ');
101101

102+
const kDefaultPrompt = '> ';
103+
102104
const kAddHistory = Symbol('_addHistory');
103105
const kBeforeEdit = Symbol('_beforeEdit');
104106
const kDecoder = Symbol('_decoder');
@@ -125,6 +127,8 @@ const kOnLine = Symbol('_onLine');
125127
const kSetLine = Symbol('_setLine');
126128
const kPreviousKey = Symbol('_previousKey');
127129
const kPrompt = Symbol('_prompt');
130+
const kPromptInvoked = Symbol('_promptInvoked');
131+
const kEffectivePrompt = Symbol('_effectivePrompt');
128132
const kPushToKillRing = Symbol('_pushToKillRing');
129133
const kPushToUndoStack = Symbol('_pushToUndoStack');
130134
const kQuestionCallback = Symbol('_questionCallback');
@@ -170,7 +174,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
170174
FunctionPrototypeCall(EventEmitter, this);
171175

172176
let crlfDelay;
173-
let prompt = '> ';
177+
let prompt = kDefaultPrompt;
174178
let signal;
175179

176180
if (input?.input) {
@@ -252,6 +256,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
252256
this.completer = completer;
253257

254258
this.setPrompt(prompt);
259+
this[kPromptInvoked] = false;
255260

256261
this.terminal = !!terminal;
257262

@@ -353,6 +358,13 @@ function InterfaceConstructor(input, output, completer, terminal) {
353358
this[kSetLine]('');
354359

355360
input.resume();
361+
362+
// If the default prompt prompt is used and the terminal is active, the prompt is automatically displayed.
363+
if (prompt === kDefaultPrompt && this.terminal && output !== null && output !== undefined) {
364+
process.nextTick(() => {
365+
this.prompt();
366+
});
367+
}
356368
}
357369

358370
ObjectSetPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype);
@@ -428,6 +440,7 @@ class Interface extends InterfaceConstructor {
428440
*/
429441
prompt(preserveCursor) {
430442
if (this.paused) this.resume();
443+
this[kPromptInvoked] = true;
431444
if (this.terminal && process.env.TERM !== 'dumb') {
432445
if (!preserveCursor) this.cursor = 0;
433446
this[kRefreshLine]();
@@ -463,6 +476,9 @@ class Interface extends InterfaceConstructor {
463476
cb(line);
464477
} else {
465478
this.emit('line', line);
479+
if (this[kPrompt] === kDefaultPrompt && this.terminal && this.output !== null && this.output !== undefined) {
480+
this.prompt();
481+
}
466482
}
467483
}
468484

@@ -490,9 +506,20 @@ class Interface extends InterfaceConstructor {
490506
return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]);
491507
}
492508

509+
[kEffectivePrompt]() {
510+
if (this[kPromptInvoked]) {
511+
return this[kPrompt];
512+
}
513+
// Issue #12606: default prompt is not painted until `prompt()`, but
514+
// non-default prompts (including multi-line prompts) participate in
515+
// layout/cursor math the same as before.
516+
return this[kPrompt] === kDefaultPrompt ? '' : this[kPrompt];
517+
}
518+
493519
[kRefreshLine]() {
494520
// line length
495-
const line = this[kPrompt] + this.line;
521+
const promptPrefix = this[kEffectivePrompt]();
522+
const line = promptPrefix + this.line;
496523
const dispPos = this[kGetDisplayPos](line);
497524
const lineCols = dispPos.cols;
498525
const lineRows = dispPos.rows;
@@ -514,7 +541,7 @@ class Interface extends InterfaceConstructor {
514541
if (this[kIsMultiline]) {
515542
const lines = StringPrototypeSplit(this.line, '\n');
516543
// Write first line with normal prompt
517-
this[kWriteToOutput](this[kPrompt] + lines[0]);
544+
this[kWriteToOutput](promptPrefix + lines[0]);
518545

519546
// For continuation lines, add the "|" prefix
520547
for (let i = 1; i < lines.length; i++) {
@@ -720,6 +747,10 @@ class Interface extends InterfaceConstructor {
720747
return;
721748
}
722749

750+
// Tab completion redraws the whole line; treat it like `prompt()` for the
751+
// purpose of painting the default prompt (see #12606).
752+
this[kPromptInvoked] = true;
753+
723754
// If there is a common prefix to all matches, then apply that portion.
724755
const prefix = commonPrefix(
725756
ArrayPrototypeFilter(completions, (e) => e !== ''),
@@ -1020,7 +1051,9 @@ class Interface extends InterfaceConstructor {
10201051
}
10211052

10221053
if (needsRewriteFirstLine) {
1023-
this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`);
1054+
this[kWriteToOutput](
1055+
`${this[kEffectivePrompt]()}${beforeCursor}\n${kMultilinePrompt.description}`,
1056+
);
10241057
} else {
10251058
this[kWriteToOutput](kMultilinePrompt.description);
10261059
}
@@ -1221,7 +1254,8 @@ class Interface extends InterfaceConstructor {
12211254
* }}
12221255
*/
12231256
getCursorPos() {
1224-
const strBeforeCursor = this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
1257+
const strBeforeCursor = this[kEffectivePrompt]() +
1258+
StringPrototypeSlice(this.line, 0, this.cursor);
12251259

12261260
return this[kGetDisplayPos](strBeforeCursor);
12271261
}

test/parallel/test-readline-interface.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,58 @@ for (let i = 0; i < 12; i++) {
12711271
}
12721272
}
12731273

1274+
// Do not paint the configured prompt on refresh until prompt() is called.
1275+
// https://github.com/nodejs/node/issues/12606
1276+
if (terminal) {
1277+
const fi = new FakeInput();
1278+
fi.isTTY = true;
1279+
fi.columns = 80;
1280+
const output = [];
1281+
fi.write = (chunk) => {
1282+
output.push(chunk.toString());
1283+
return true;
1284+
};
1285+
1286+
const rli = readline.createInterface({
1287+
input: fi,
1288+
output: fi,
1289+
terminal: true,
1290+
});
1291+
1292+
rli.write('a');
1293+
output.length = 0;
1294+
rli.write(undefined, { name: 'backspace' });
1295+
assert.strictEqual(output.join('').includes('> '), false);
1296+
rli.close();
1297+
}
1298+
1299+
// gh-12606: redraw each new line by calling `prompt()` from `'line'` (REPL pattern).
1300+
if (terminal) {
1301+
const fi = new FakeInput();
1302+
fi.isTTY = true;
1303+
fi.columns = 80;
1304+
const output = [];
1305+
fi.write = (chunk) => {
1306+
output.push(chunk.toString());
1307+
return true;
1308+
};
1309+
1310+
const rli = readline.createInterface({
1311+
input: fi,
1312+
output: fi,
1313+
terminal: true,
1314+
});
1315+
1316+
rli.prompt(false);
1317+
rli.on('line', () => {
1318+
rli.prompt(false);
1319+
});
1320+
output.length = 0;
1321+
fi.emit('data', 'x\n');
1322+
assert.strictEqual(output.join('').includes('> '), true);
1323+
rli.close();
1324+
}
1325+
12741326
{
12751327
const expected = terminal ?
12761328
['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] :

0 commit comments

Comments
 (0)