From 700207b39aa6e97862df9d474f024d893b3405b9 Mon Sep 17 00:00:00 2001 From: TseIan Date: Sat, 18 Apr 2026 10:13:57 +0800 Subject: [PATCH] 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: https://github.com/nodejs/node/issues/12606 Assisted-by: Cursor IDE --- doc/api/readline.md | 11 +++++ lib/internal/readline/interface.js | 44 +++++++++++++++++--- test/parallel/test-readline-interface.js | 52 ++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) 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'] :