diff --git a/doc/api/readline.md b/doc/api/readline.md index 02e56e4c6a322b..240cc3fb9388a3 100644 --- a/doc/api/readline.md +++ b/doc/api/readline.md @@ -321,6 +321,10 @@ location at which to provide input. When called, `rl.prompt()` will resume the `input` stream if it has been paused. +To show the prompt on each new line of input, call `rl.prompt()` from your +`'line'` event listener (the built-in REPL does this after evaluating each +line). + If the `InterfaceConstructor` was created with `output` set to `null` or `undefined` the prompt is not written. @@ -710,6 +714,9 @@ added: v17.0.0 to the history list duplicates an older one, this removes the older line from the list. **Default:** `false`. * `prompt` {string} The prompt string to use. **Default:** `'> '`. + For TTY interfaces, the default prompt is not written when the line is + redrawn until `rl.prompt()` has been called at least once (see + [`rl.prompt()`][]). A non-default `prompt` is written on redraw as before. * `crlfDelay` {number} If the delay between `\r` and `\n` exceeds `crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate end-of-line input. `crlfDelay` will be coerced to a number no less than @@ -975,6 +982,9 @@ changes: to the history list duplicates an older one, this removes the older line from the list. **Default:** `false`. * `prompt` {string} The prompt string to use. **Default:** `'> '`. + For TTY interfaces, the default prompt is not written when the line is + redrawn until `rl.prompt()` has been called at least once (see + [`rl.prompt()`][]). A non-default `prompt` is written on redraw as before. * `crlfDelay` {number} If the delay between `\r` and `\n` exceeds `crlfDelay` milliseconds, both `\r` and `\n` will be treated as separate end-of-line input. `crlfDelay` will be coerced to a number no less than @@ -1492,4 +1502,5 @@ const { createInterface } = require('node:readline'); [`process.stdin`]: process.md#processstdin [`process.stdout`]: process.md#processstdout [`rl.close()`]: #rlclose +[`rl.prompt()`]: #rlpromptpreservecursor [reading files]: #example-read-file-stream-line-by-line diff --git a/lib/internal/readline/interface.js b/lib/internal/readline/interface.js index 08f7aaa9e3e7e8..1cf2f34fa8d1ba 100644 --- a/lib/internal/readline/interface.js +++ b/lib/internal/readline/interface.js @@ -99,6 +99,8 @@ const kMaxLengthOfKillRing = 32; const kMultilinePrompt = Symbol('| '); +const kDefaultPrompt = '> '; + const kAddHistory = Symbol('_addHistory'); const kBeforeEdit = Symbol('_beforeEdit'); const kDecoder = Symbol('_decoder'); @@ -125,6 +127,8 @@ const kOnLine = Symbol('_onLine'); const kSetLine = Symbol('_setLine'); const kPreviousKey = Symbol('_previousKey'); const kPrompt = Symbol('_prompt'); +const kPromptInvoked = Symbol('_promptInvoked'); +const kEffectivePrompt = Symbol('_effectivePrompt'); const kPushToKillRing = Symbol('_pushToKillRing'); const kPushToUndoStack = Symbol('_pushToUndoStack'); const kQuestionCallback = Symbol('_questionCallback'); @@ -170,7 +174,7 @@ function InterfaceConstructor(input, output, completer, terminal) { FunctionPrototypeCall(EventEmitter, this); let crlfDelay; - let prompt = '> '; + let prompt = kDefaultPrompt; let signal; if (input?.input) { @@ -252,6 +256,7 @@ function InterfaceConstructor(input, output, completer, terminal) { this.completer = completer; this.setPrompt(prompt); + this[kPromptInvoked] = false; this.terminal = !!terminal; @@ -353,6 +358,13 @@ function InterfaceConstructor(input, output, completer, terminal) { this[kSetLine](''); input.resume(); + + // If the default prompt prompt is used and the terminal is active, the prompt is automatically displayed. + if (prompt === kDefaultPrompt && this.terminal && output !== null && output !== undefined) { + process.nextTick(() => { + this.prompt(); + }); + } } ObjectSetPrototypeOf(InterfaceConstructor.prototype, EventEmitter.prototype); @@ -428,6 +440,7 @@ class Interface extends InterfaceConstructor { */ prompt(preserveCursor) { if (this.paused) this.resume(); + this[kPromptInvoked] = true; if (this.terminal && process.env.TERM !== 'dumb') { if (!preserveCursor) this.cursor = 0; this[kRefreshLine](); @@ -463,6 +476,9 @@ class Interface extends InterfaceConstructor { cb(line); } else { this.emit('line', line); + if (this[kPrompt] === kDefaultPrompt && this.terminal && this.output !== null && this.output !== undefined) { + this.prompt(); + } } } @@ -490,9 +506,20 @@ class Interface extends InterfaceConstructor { return this.historyManager.addHistory(this[kIsMultiline], this[kLastCommandErrored]); } + [kEffectivePrompt]() { + if (this[kPromptInvoked]) { + return this[kPrompt]; + } + // Issue #12606: default prompt is not painted until `prompt()`, but + // non-default prompts (including multi-line prompts) participate in + // layout/cursor math the same as before. + return this[kPrompt] === kDefaultPrompt ? '' : this[kPrompt]; + } + [kRefreshLine]() { // line length - const line = this[kPrompt] + this.line; + const promptPrefix = this[kEffectivePrompt](); + const line = promptPrefix + this.line; const dispPos = this[kGetDisplayPos](line); const lineCols = dispPos.cols; const lineRows = dispPos.rows; @@ -514,7 +541,7 @@ class Interface extends InterfaceConstructor { if (this[kIsMultiline]) { const lines = StringPrototypeSplit(this.line, '\n'); // Write first line with normal prompt - this[kWriteToOutput](this[kPrompt] + lines[0]); + this[kWriteToOutput](promptPrefix + lines[0]); // For continuation lines, add the "|" prefix for (let i = 1; i < lines.length; i++) { @@ -720,6 +747,10 @@ class Interface extends InterfaceConstructor { return; } + // Tab completion redraws the whole line; treat it like `prompt()` for the + // purpose of painting the default prompt (see #12606). + this[kPromptInvoked] = true; + // If there is a common prefix to all matches, then apply that portion. const prefix = commonPrefix( ArrayPrototypeFilter(completions, (e) => e !== ''), @@ -1020,7 +1051,9 @@ class Interface extends InterfaceConstructor { } if (needsRewriteFirstLine) { - this[kWriteToOutput](`${this[kPrompt]}${beforeCursor}\n${kMultilinePrompt.description}`); + this[kWriteToOutput]( + `${this[kEffectivePrompt]()}${beforeCursor}\n${kMultilinePrompt.description}`, + ); } else { this[kWriteToOutput](kMultilinePrompt.description); } @@ -1221,7 +1254,8 @@ class Interface extends InterfaceConstructor { * }} */ getCursorPos() { - const strBeforeCursor = this[kPrompt] + StringPrototypeSlice(this.line, 0, this.cursor); + const strBeforeCursor = this[kEffectivePrompt]() + + StringPrototypeSlice(this.line, 0, this.cursor); return this[kGetDisplayPos](strBeforeCursor); } diff --git a/test/parallel/test-readline-interface.js b/test/parallel/test-readline-interface.js index 2fd4646c314c84..6a135f50f6526f 100644 --- a/test/parallel/test-readline-interface.js +++ b/test/parallel/test-readline-interface.js @@ -1271,6 +1271,58 @@ for (let i = 0; i < 12; i++) { } } + // Do not paint the configured prompt on refresh until prompt() is called. + // https://github.com/nodejs/node/issues/12606 + if (terminal) { + const fi = new FakeInput(); + fi.isTTY = true; + fi.columns = 80; + const output = []; + fi.write = (chunk) => { + output.push(chunk.toString()); + return true; + }; + + const rli = readline.createInterface({ + input: fi, + output: fi, + terminal: true, + }); + + rli.write('a'); + output.length = 0; + rli.write(undefined, { name: 'backspace' }); + assert.strictEqual(output.join('').includes('> '), false); + rli.close(); + } + + // gh-12606: redraw each new line by calling `prompt()` from `'line'` (REPL pattern). + if (terminal) { + const fi = new FakeInput(); + fi.isTTY = true; + fi.columns = 80; + const output = []; + fi.write = (chunk) => { + output.push(chunk.toString()); + return true; + }; + + const rli = readline.createInterface({ + input: fi, + output: fi, + terminal: true, + }); + + rli.prompt(false); + rli.on('line', () => { + rli.prompt(false); + }); + output.length = 0; + fi.emit('data', 'x\n'); + assert.strictEqual(output.join('').includes('> '), true); + rli.close(); + } + { const expected = terminal ? ['\u001b[1G', '\u001b[0J', '$ ', '\u001b[3G'] :