Skip to content

Bug: CLI:prompt() with readline backspace removes label. #10506

Description

@karlgray

PHP Version

8.4

CodeIgniter4 Version

4.7.4

CodeIgniter4 Installation Method

Composer (using codeigniter4/appstarter)

Which operating systems have you tested for this bug?

Linux

Which server did you use?

cli-server (PHP built-in webserver)

Environment

development

Database

MariaDB 10.11

What happened?

Description

When a command asks a question with CLI::prompt() and the user presses backspace to
correct a typo, the prompt itself is erased along with the typed characters. Keep pressing
backspace and Email : disappears from the screen entirely, leaving the cursor at the
start of an apparently blank line.

The value is not actually damaged — readline() still returns the correct string, and if
you carry on typing blind the command works. But you cannot see what you are typing, so in
practice you abandon the entry and start again.

This only happens when the readline extension is loaded. Without it, CLI::prompt() falls
through to fgets() and the terminal handles backspace correctly, which is probably why the
bug has gone unnoticed.

Steps to reproduce

On a machine with ext-readline enabled, in any command:

$email = CLI::prompt('Email', null, 'required|valid_email');

Run it, type a few characters, then hold backspace. The typed characters go, then : , then
Email.

Expected behaviour

Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.

Actual behaviour

Backspace deletes all the typed characters on first backspace and then continues over the prompt text, erasing it.

Cause

CLI::prompt() writes the prompt to STDOUT itself, and then calls the input reader without
passing it
:

// system/CLI/CLI.php
static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
static::$lastWrite = 'write';

// Read the input from keyboard.
$input = trim(static::$io->input());   // <- no prefix

InputOutput::input() therefore calls readline(null).

readline() redraws the whole line every time an editing key is pressed, and it draws
prompt + buffer starting from the beginning of the line. Because it was handed an empty
prompt, it believes the line begins at column 0 — which is where Email : already is — so
each redraw paints the shrinking input over the prompt.

Capturing the raw bytes sent to the terminal for the keystrokes boX followed by three
backspaces makes it visible:

boX <CR> bo <ESC> <CR> b <ESC> <CR> <ESC> boss@example.com
    ^^^^ carriage return to column 0, then repaint — on top of "Email : "

Workaround

If you need working prompts today, skip CLI::prompt() and call CLI::input() yourself,
passing the prompt text as its argument. That is the one thing CLI::prompt() fails to do,
and it is enough to make backspace behave. The arrow keys start working too.

CLI::input() does no validation, so you wrap it in your own loop:

private function ask(string $label, string $field, array $rules): string
{
    while (true) {
        $answer = trim(CLI::input($label . ': '));

        $validation = Services::validation(null, false);
        $validation->setRules([$field => $rules[$field]]);

        if ($validation->run([$field => $answer])) {
            return $answer;
        }

        foreach ($validation->getErrors() as $message) {
            CLI::error($message);
        }
    }
}

Two things to know before copying it. It is narrower than CLI::prompt() — no default
values and no option lists — so it suits free-text questions and not much else. And use a
fresh validator rather than the shared one, as above: Validation keeps the data and errors
of its last run, so a shared instance goes on reporting the mistake the user has just
corrected.

Suggested fix - Created with AI so you can stop reading this section if that offends.

Hand the prompt to the reader instead of writing it first, so readline() knows how wide it
is.

system/CLI/CLI.php, in prompt():

-        static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
         static::$lastWrite = 'write';
 
-        // Read the input from keyboard.
-        $input = trim(static::$io->input());
+        // Read the input from keyboard. The prompt is passed to the reader rather than
+        // written first: readline() redraws the whole line on every editing key, and can
+        // only avoid painting over a prompt it was given.
+        $input = trim(static::$io->input(
+            $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': '
+        ));

One trap worth knowing about

That change alone is not enough, and doing only half of it would swap one display bug for
another. Any prompt offering a default value or a list of options builds $extraOutput with
CLI::color(), so the prompt string contains ANSI escape sequences. readline() counts
those invisible bytes as visible columns and places the cursor too far right on every
redraw.

Measured with the prompt Colour [white]: (16 visible characters, [white] coloured),
after typing three characters and pressing backspace once — the cursor should land in
column 19:

prompt given to readline() cursor redraw emitted correct?
raw ANSI ESC[28G no — 9 columns adrift, exactly the escape sequences' byte count
\001/\002 bracketed ESC[19G yes

\001 and \002 are readline's RL_PROMPT_START_IGNORE and RL_PROMPT_END_IGNORE
markers, which tell it "these bytes take up no width". They belong only on the readline
branch — the echo $prefix fallback must receive the prompt unchanged, or the markers show
up as stray control characters.

system/CLI/InputOutput.php, in input():

     public function input(?string $prefix = null): string
     {
         // readline() can't be tested.
         if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
-            return readline($prefix); // @codeCoverageIgnore
+            return readline(self::markNonPrinting($prefix)); // @codeCoverageIgnore
         }
 
         echo $prefix;
/**
 * Marks ANSI sequences in a prompt as non-printing.
 *
 * readline() counts the prompt's characters to know where the editable part of the
 * line starts. Without these markers it counts the bytes of a colour escape as
 * visible columns and misplaces the cursor on every redraw.
 */
private static function markNonPrinting(?string $prefix): string
{
    return preg_replace('/(\033\[[0-9;]*m)/', "\001$1\002", (string) $prefix);
}

Note for whoever picks this up

The prompt now reaches STDOUT through input() rather than CLI::fwrite(). That changes
how it is captured under test, so the existing CLI tests that assert on prompt output need
checking and possibly adjusting — that is the part of this change most likely to need work,
rather than the fix itself.

CLI::promptByMultipleKeys() builds its prompt the same way and should be checked at the
same time.

Environment

  • CodeIgniter 4.7.4
  • PHP 8.4.24 with ext-readline loaded
  • Linux, xterm-compatible terminal

The behaviour does not depend on the terminal emulator; it is reproducible anywhere
readline() is in use.

Steps to Reproduce

Steps to reproduce

On a machine with ext-readline enabled, in any command:

$email = CLI::prompt('Email', null, 'required|valid_email');

Run it, type a few characters, then press back backspace then backspace again. The typed characters go, then : , then
Email.

Expected Output

Backspace deletes the characters the user typed and stops at the prompt, as it does in every
other readline-driven prompt.

Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugVerified issues on the current code behavior or pull requests that will fix them

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions