Skip to content

Commit 263b532

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 818db3a commit 263b532

3 files changed

Lines changed: 89 additions & 4 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: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ const kOnLine = Symbol('_onLine');
127127
const kSetLine = Symbol('_setLine');
128128
const kPreviousKey = Symbol('_previousKey');
129129
const kPrompt = Symbol('_prompt');
130+
const kPromptInvoked = Symbol('_promptInvoked');
131+
const kEffectivePrompt = Symbol('_effectivePrompt');
130132
const kPushToKillRing = Symbol('_pushToKillRing');
131133
const kPushToUndoStack = Symbol('_pushToUndoStack');
132134
const kQuestionCallback = Symbol('_questionCallback');
@@ -254,6 +256,7 @@ function InterfaceConstructor(input, output, completer, terminal) {
254256
this.completer = completer;
255257

256258
this.setPrompt(prompt);
259+
this[kPromptInvoked] = false;
257260

258261
this.terminal = !!terminal;
259262

@@ -437,6 +440,7 @@ class Interface extends InterfaceConstructor {
437440
*/
438441
prompt(preserveCursor) {
439442
if (this.paused) this.resume();
443+
this[kPromptInvoked] = true;
440444
if (this.terminal && process.env.TERM !== 'dumb') {
441445
if (!preserveCursor) this.cursor = 0;
442446
this[kRefreshLine]();
@@ -502,9 +506,20 @@ class Interface extends InterfaceConstructor {
502506
return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]);
503507
}
504508

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+
505519
[kRefreshLine]() {
506520
// line length
507-
const line = this[kPrompt] + this.line;
521+
const promptPrefix = this[kEffectivePrompt]();
522+
const line = promptPrefix + this.line;
508523
const dispPos = this[kGetDisplayPos](line);
509524
const lineCols = dispPos.cols;
510525
const lineRows = dispPos.rows;
@@ -526,7 +541,7 @@ class Interface extends InterfaceConstructor {
526541
if (this[kIsMultiline]) {
527542
const lines = StringPrototypeSplit(this.line, '\n');
528543
// Write first line with normal prompt
529-
this[kWriteToOutput](this[kPrompt] + lines[0]);
544+
this[kWriteToOutput](promptPrefix + lines[0]);
530545

531546
// For continuation lines, add the "|" prefix
532547
for (let i = 1; i < lines.length; i++) {
@@ -732,6 +747,10 @@ class Interface extends InterfaceConstructor {
732747
return;
733748
}
734749

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+
735754
// If there is a common prefix to all matches, then apply that portion.
736755
const prefix = commonPrefix(
737756
ArrayPrototypeFilter(completions, (e) => e !== ''),
@@ -1032,7 +1051,9 @@ class Interface extends InterfaceConstructor {
10321051
}
10331052

10341053
if (needsRewriteFirstLine) {
1035-
this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`);
1054+
this[kWriteToOutput](
1055+
`${this[kEffectivePrompt]()}${beforeCursor}\n${kMultilinePrompt.description}`,
1056+
);
10361057
} else {
10371058
this[kWriteToOutput](kMultilinePrompt.description);
10381059
}
@@ -1233,7 +1254,8 @@ class Interface extends InterfaceConstructor {
12331254
* }}
12341255
*/
12351256
getCursorPos() {
1236-
const strBeforeCursor = this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor);
1257+
const strBeforeCursor = this[kEffectivePrompt]() +
1258+
StringPrototypeSlice(this.line, 0, this.cursor);
12371259

12381260
return this[kGetDisplayPos](strBeforeCursor);
12391261
}

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)